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:
commit
6292cdfe1c
@ -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 }}
|
||||
|
||||
16
.github/workflows/pub-aur.yml
vendored
16
.github/workflows/pub-aur.yml
vendored
@ -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"
|
||||
|
||||
6
.github/workflows/pub-homebrew-core.yml
vendored
6
.github/workflows/pub-homebrew-core.yml
vendored
@ -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."
|
||||
|
||||
5
.github/workflows/release-beta-on-push.yml
vendored
5
.github/workflows/release-beta-on-push.yml
vendored
@ -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
|
||||
|
||||
5
.github/workflows/release-stable-manual.yml
vendored
5
.github/workflows/release-stable-manual.yml
vendored
@ -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
2
Cargo.lock
generated
@ -9164,7 +9164,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-imap",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -76,7 +76,7 @@ runtime_trace_max_entries = 200
|
||||
|
||||
| 键 | 默认值 | 用途 |
|
||||
|---|---|---|
|
||||
| `compact_context` | `false` | 为 true 时:bootstrap_max_chars=6000,rag_chunk_limit=2。适用于 13B 或更小的模型 |
|
||||
| `compact_context` | `true` | 为 true 时:bootstrap_max_chars=6000,rag_chunk_limit=2。适用于 13B 或更小的模型 |
|
||||
| `max_tool_iterations` | `10` | 跨 CLI、网关和渠道的每条用户消息的最大工具调用循环轮次 |
|
||||
| `max_history_messages` | `50` | 每个会话保留的最大对话历史消息数 |
|
||||
| `parallel_tools` | `false` | 在单次迭代中启用并行工具执行 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
40
install.sh
40
install.sh
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
@ -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![])
|
||||
|
||||
@ -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"));
|
||||
|
||||
152
src/cron/mod.rs
152
src/cron/mod.rs
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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))
|
||||
|
||||
15
src/lib.rs
15
src/lib.rs
@ -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 {
|
||||
|
||||
@ -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."
|
||||
));
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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#"{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>"));
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -418,6 +418,7 @@ impl DelegateTool {
|
||||
true,
|
||||
None,
|
||||
"delegate",
|
||||
None,
|
||||
&self.multimodal_config,
|
||||
agent_config.max_iterations,
|
||||
None,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
187
src/tools/read_skill.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
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
BIN
web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user