Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afb2e20a79 | |||
| 776e5947ef | |||
| e2183c89a3 | |||
| 29dc1172c0 | |||
| e79e1b88b7 | |||
| f886ce47e9 | |||
| 6a30e24e7b | |||
| 83587eea4a | |||
| 226b2282f5 | |||
| 7bf5f3edde | |||
| 191192a104 | |||
| 8b942853c4 | |||
| 95473d83b5 | |||
| b5668acf2f | |||
| 2128c9db5b | |||
| 8d3d14f1e4 | |||
| 632d513c2e | |||
| ade588b4ec | |||
| f7636ab81c | |||
| d76e4e5a86 | |||
| d9cea87fae | |||
| 6213bcab07 | |||
| fe9f58f917 | |||
| 04c7ce4488 | |||
| 5eea95ef2a | |||
| af1c37c2fb | |||
| e3e4aef21c | |||
| a48e335be9 | |||
| fba15520dc | |||
| 7504da1117 | |||
| 6292cdfe1c | |||
| 693661b564 | |||
| 4daec8c0df | |||
| 3cf609cb38 | |||
| e1b7d29f1b | |||
| fef69a4128 | |||
| 643b683c39 | |||
| 74c93b0ebc | |||
| a7bf69d279 | |||
| f68af9a4c7 | |||
| cca3d66955 | |||
| 95bf229225 | |||
| ebe19147f2 | |||
| e387f58579 | |||
| fa06798926 | |||
| a4cd4b287e | |||
| 3ce7f2345e | |||
| eb9dfc04b4 | |||
| 9cc74a2698 | |||
| 133dc46b41 | |||
| ad03605cad | |||
| ae1acf9b9c | |||
| cc91f22e9b | |||
| 030f5fe288 | |||
| c47bbcc972 | |||
| 72fbb22059 | |||
| cbb3d9ae92 | |||
| 8d1eebad4d | |||
| 0fdd1ad490 | |||
| 86bc60fcd1 | |||
| 4837e1fe73 | |||
| 985977ae0c | |||
| 72b10f12dd | |||
| 3239f5ea07 | |||
| 3353729b01 | |||
| b6c2930a70 | |||
| 181cafff70 | |||
| d87f387111 | |||
| 7068079028 | |||
| a9b511e6ec | |||
| 65cb4fe099 | |||
| 1bbc159e0e | |||
| 0d28cca843 | |||
| b1d20d38f9 | |||
| 2bad6678ec | |||
| b6fe054915 | |||
| 7ddd2aace3 | |||
| c7b3b762e0 | |||
| 4b00e8ba75 | |||
| dd462a2b04 | |||
| 2d68b880c2 | |||
| 3a672a2ede | |||
| 2e48cbf7c3 | |||
| e4910705d1 | |||
| 1b664143c2 | |||
| 950f996812 | |||
| b74c5cfda8 | |||
| 02688eb124 | |||
| 2c92cf913b | |||
| 3c117d2d7b | |||
| 1f7c3c99e4 | |||
| 92940a3d16 | |||
| d77c616905 | |||
| ac12470c27 | |||
| a322e01b5f | |||
| c5a1148ae9 | |||
| 440ad6e5b5 | |||
| 2e41cb56f6 | |||
| 2227fadb66 | |||
| 162efbb49c | |||
| 3c8b6d219a | |||
| 58b98c59a8 | |||
| d72e9379f7 | |||
| e3e9db5210 | |||
| ad8a209bd7 | |||
| 571ccd67cb | |||
| 959b933841 | |||
| caf7c7194f | |||
| ee7d542da6 | |||
| d51ec4b43f | |||
| d81eeefe52 | |||
| 3d92b2a652 | |||
| 3255051426 | |||
| dcaf330848 | |||
| 7f8de5cb17 | |||
| 1341cfb296 | |||
| 9da620a5aa | |||
| d016e6b1a0 | |||
| 9b6360ad71 | |||
| dc50ca9171 | |||
| 67edd2bc60 | |||
| dcf66175e4 | |||
| b3bb79d805 | |||
| c857b64bb4 | |||
| f87c7442b9 | |||
| 0a191fc02c | |||
| bb99d2b57a | |||
| 81256dbf42 | |||
| eb9b26cea0 | |||
| 6211824f01 | |||
| b4decb40c6 | |||
| 2b30f060fe | |||
| f994979380 | |||
| 04ea5093d4 |
@@ -0,0 +1,10 @@
|
||||
# cargo-audit configuration
|
||||
# https://rustsec.org/
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# wasmtime vulns via extism 1.13.0 — no upstream fix; plugins feature-gated
|
||||
"RUSTSEC-2026-0006", # wasmtime f64.copysign segfault on x86-64
|
||||
"RUSTSEC-2026-0020", # WASI guest-controlled resource exhaustion
|
||||
"RUSTSEC-2026-0021", # WASI http fields panic
|
||||
]
|
||||
@@ -1 +1,61 @@
|
||||
# Git attributes for ZeroClaw
|
||||
# https://git-scm.com/docs/gitattributes
|
||||
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Source code
|
||||
*.rs text eol=lf linguist-language=Rust
|
||||
*.toml text eol=lf linguist-language=TOML
|
||||
*.py text eol=lf linguist-language=Python
|
||||
*.js text eol=lf linguist-language=JavaScript
|
||||
*.ts text eol=lf linguist-language=TypeScript
|
||||
*.html text eol=lf linguist-language=HTML
|
||||
*.css text eol=lf linguist-language=CSS
|
||||
*.scss text eol=lf linguist-language=SCSS
|
||||
*.json text eol=lf linguist-language=JSON
|
||||
*.yaml text eol=lf linguist-language=YAML
|
||||
*.yml text eol=lf linguist-language=YAML
|
||||
*.md text eol=lf linguist-language=Markdown
|
||||
*.sh text eol=lf linguist-language=Shell
|
||||
*.bash text eol=lf linguist-language=Shell
|
||||
*.ps1 text eol=crlf linguist-language=PowerShell
|
||||
|
||||
# Documentation
|
||||
*.txt text eol=lf
|
||||
LICENSE* text eol=lf
|
||||
|
||||
# Configuration files
|
||||
.editorconfig text eol=lf
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.dockerignore text eol=lf
|
||||
|
||||
# Rust-specific
|
||||
Cargo.lock text eol=lf linguist-generated
|
||||
Cargo.toml text eol=lf
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout
|
||||
*.sln text eol=crlf
|
||||
|
||||
# Denote all files that are truly binary and should not be modified
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.svg text
|
||||
*.wasm binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.eot binary
|
||||
*.mp3 binary
|
||||
*.mp4 binary
|
||||
*.webm binary
|
||||
*.zip binary
|
||||
*.tar binary
|
||||
*.gz binary
|
||||
*.bz2 binary
|
||||
*.7z binary
|
||||
*.db binary
|
||||
|
||||
@@ -133,6 +133,29 @@ jobs:
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
|
||||
check-all-features:
|
||||
name: Check (all features)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
|
||||
- name: Install system dependencies
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y libudev-dev
|
||||
|
||||
- name: Ensure web/dist placeholder exists
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
|
||||
- name: Check all features
|
||||
run: cargo check --all-features --locked
|
||||
|
||||
docs-quality:
|
||||
name: Docs Quality
|
||||
runs-on: ubuntu-latest
|
||||
@@ -157,7 +180,7 @@ jobs:
|
||||
gate:
|
||||
name: CI Required Gate
|
||||
if: always()
|
||||
needs: [lint, lint-strict-delta, test, build, docs-quality]
|
||||
needs: [lint, lint-strict-delta, test, build, docs-quality, check-all-features]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check upstream job results
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+1260
-44
File diff suppressed because it is too large
Load Diff
+18
-11
@@ -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"
|
||||
@@ -14,15 +14,6 @@ readme = "README.md"
|
||||
keywords = ["ai", "agent", "cli", "assistant", "chatbot"]
|
||||
categories = ["command-line-utilities", "api-bindings"]
|
||||
rust-version = "1.87"
|
||||
|
||||
[[bin]]
|
||||
name = "zeroclaw"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "zeroclaw"
|
||||
path = "src/lib.rs"
|
||||
|
||||
include = [
|
||||
"/src/**/*",
|
||||
"/build.rs",
|
||||
@@ -31,8 +22,17 @@ include = [
|
||||
"/LICENSE*",
|
||||
"/README.md",
|
||||
"/web/dist/**/*",
|
||||
"/tool_descriptions/**/*",
|
||||
]
|
||||
|
||||
[[bin]]
|
||||
name = "zeroclaw"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "zeroclaw"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
# CLI - minimal and fast
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
@@ -190,6 +190,9 @@ probe-rs = { version = "0.31", optional = true }
|
||||
# PDF extraction for datasheet RAG (optional, enable with --features rag-pdf)
|
||||
pdf-extract = { version = "0.10", optional = true }
|
||||
|
||||
# WASM plugin runtime (extism)
|
||||
extism = { version = "1.9", optional = true }
|
||||
|
||||
# Terminal QR rendering for WhatsApp Web pairing flow.
|
||||
qrcode = { version = "0.14", optional = true }
|
||||
|
||||
@@ -212,7 +215,7 @@ landlock = { version = "0.4", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = ["observability-prometheus", "channel-nostr"]
|
||||
default = ["observability-prometheus", "channel-nostr", "skill-creation"]
|
||||
channel-nostr = ["dep:nostr-sdk"]
|
||||
hardware = ["nusb", "tokio-serial"]
|
||||
channel-matrix = ["dep:matrix-sdk"]
|
||||
@@ -237,8 +240,12 @@ metrics = ["observability-prometheus"]
|
||||
probe = ["dep:probe-rs"]
|
||||
# rag-pdf = PDF ingestion for datasheet RAG
|
||||
rag-pdf = ["dep:pdf-extract"]
|
||||
# skill-creation = Autonomous skill creation from successful multi-step tasks
|
||||
skill-creation = []
|
||||
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
|
||||
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
|
||||
# WASM plugin system (extism-based)
|
||||
plugins-wasm = ["dep:extism"]
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
|
||||
+10
-11
@@ -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 \
|
||||
@@ -23,13 +23,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
# 1. Copy manifests to cache dependencies
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
|
||||
# Remove robot-kit from workspace members — it is excluded by .dockerignore
|
||||
# and is not needed for the Docker build (hardware-only crate).
|
||||
RUN sed -i 's/members = \[".", "crates\/robot-kit"\]/members = ["."]/' Cargo.toml
|
||||
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
|
||||
RUN mkdir -p src benches crates/robot-kit/src \
|
||||
RUN mkdir -p src benches \
|
||||
&& echo "fn main() {}" > src/main.rs \
|
||||
&& echo "" > src/lib.rs \
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
|
||||
&& echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
@@ -38,13 +39,11 @@ RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/regist
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi
|
||||
RUN rm -rf src benches crates/robot-kit/src
|
||||
RUN rm -rf src benches
|
||||
|
||||
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
|
||||
COPY src/ src/
|
||||
COPY benches/ benches/
|
||||
COPY crates/ crates/
|
||||
COPY firmware/ firmware/
|
||||
COPY --from=web-builder /web/dist web/dist
|
||||
COPY *.rs .
|
||||
RUN touch src/main.rs
|
||||
@@ -117,10 +116,10 @@ EXPOSE 42617
|
||||
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD ["zeroclaw", "status", "--format=exit-code"]
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
CMD ["daemon"]
|
||||
|
||||
# ── Stage 3: Production Runtime (Distroless) ─────────────────
|
||||
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7 AS release
|
||||
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:9c4fe2381c2e6d53c4cfdefeff6edbd2a67ec7713e2c3ca6653806cbdbf27a1e AS release
|
||||
|
||||
COPY --from=builder /app/zeroclaw /usr/local/bin/zeroclaw
|
||||
COPY --from=builder /zeroclaw-data /zeroclaw-data
|
||||
@@ -143,4 +142,4 @@ EXPOSE 42617
|
||||
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD ["zeroclaw", "status", "--format=exit-code"]
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
CMD ["daemon"]
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
# The main Dockerfile is still used for local dev builds.
|
||||
|
||||
# ── Runtime (Distroless) ─────────────────────────────────────
|
||||
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7
|
||||
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:9c4fe2381c2e6d53c4cfdefeff6edbd2a67ec7713e2c3ca6653806cbdbf27a1e
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
|
||||
+8
-9
@@ -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 \
|
||||
@@ -38,13 +38,14 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
# 1. Copy manifests to cache dependencies
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
|
||||
# Remove robot-kit from workspace members — it is excluded by .dockerignore
|
||||
# and is not needed for the Docker build (hardware-only crate).
|
||||
RUN sed -i 's/members = \[".", "crates\/robot-kit"\]/members = ["."]/' Cargo.toml
|
||||
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
|
||||
RUN mkdir -p src benches crates/robot-kit/src \
|
||||
RUN mkdir -p src benches \
|
||||
&& echo "fn main() {}" > src/main.rs \
|
||||
&& echo "" > src/lib.rs \
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
|
||||
&& echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
@@ -53,13 +54,11 @@ RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/regist
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi
|
||||
RUN rm -rf src benches crates/robot-kit/src
|
||||
RUN rm -rf src benches
|
||||
|
||||
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
|
||||
COPY src/ src/
|
||||
COPY benches/ benches/
|
||||
COPY crates/ crates/
|
||||
COPY firmware/ firmware/
|
||||
COPY --from=web-builder /web/dist web/dist
|
||||
RUN touch src/main.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
@@ -123,4 +122,4 @@ EXPOSE 42617
|
||||
HEALTHCHECK --interval=60s --timeout=10s --retries=3 --start-period=10s \
|
||||
CMD ["zeroclaw", "status", "--format=exit-code"]
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
CMD ["daemon"]
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Justfile - Convenient command runner for ZeroClaw development
|
||||
# https://github.com/casey/just
|
||||
|
||||
# Default recipe to display help
|
||||
_default:
|
||||
@just --list
|
||||
|
||||
# Format all code
|
||||
fmt:
|
||||
cargo fmt --all
|
||||
|
||||
# Check formatting without making changes
|
||||
fmt-check:
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# Run clippy lints
|
||||
lint:
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Run all tests
|
||||
test:
|
||||
cargo test --locked
|
||||
|
||||
# Run only unit tests (faster)
|
||||
test-lib:
|
||||
cargo test --lib
|
||||
|
||||
# Run the full CI quality gate locally
|
||||
ci: fmt-check lint test
|
||||
@echo "✅ All CI checks passed!"
|
||||
|
||||
# Build in release mode
|
||||
build:
|
||||
cargo build --release --locked
|
||||
|
||||
# Build in debug mode
|
||||
build-debug:
|
||||
cargo build
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
cargo clean
|
||||
|
||||
# Run zeroclaw with example config (for development)
|
||||
dev *ARGS:
|
||||
cargo run -- {{ARGS}}
|
||||
|
||||
# Check code without building
|
||||
check:
|
||||
cargo check --all-targets
|
||||
|
||||
# Run cargo doc and open in browser
|
||||
doc:
|
||||
cargo doc --no-deps --open
|
||||
|
||||
# Update dependencies
|
||||
update:
|
||||
cargo update
|
||||
|
||||
# Run cargo audit to check for security vulnerabilities
|
||||
audit:
|
||||
cargo audit
|
||||
|
||||
# Run cargo deny checks
|
||||
deny:
|
||||
cargo deny check
|
||||
|
||||
# Format TOML files (requires taplo)
|
||||
fmt-toml:
|
||||
taplo format
|
||||
|
||||
# Check TOML formatting (requires taplo)
|
||||
fmt-toml-check:
|
||||
taplo format --check
|
||||
|
||||
# Run all formatting tools
|
||||
fmt-all: fmt fmt-toml
|
||||
@echo "✅ All formatting complete!"
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center" dir="rtl">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -16,7 +16,11 @@
|
||||
<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://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center" dir="rtl">
|
||||
@@ -103,7 +107,7 @@
|
||||
| التاريخ (UTC) | المستوى | الإشعار | الإجراء |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _حرج_ | **نحن غير مرتبطين** بـ `openagen/zeroclaw` أو `zeroclaw.org`. نطاق `zeroclaw.org` يشير حاليًا إلى الفرع `openagen/zeroclaw`، وهذا النطاق/المستودع ينتحل شخصية موقعنا/مشروعنا الرسمي. | لا تثق بالمعلومات أو الملفات الثنائية أو جمع التبرعات أو الإعلانات من هذه المصادر. استخدم فقط [هذا المستودع](https://github.com/zeroclaw-labs/zeroclaw) وحساباتنا الموثقة على وسائل التواصل الاجتماعي. |
|
||||
| 2026-02-21 | _مهم_ | موقعنا الرسمي أصبح متاحًا الآن: [zeroclawlabs.ai](https://zeroclawlabs.ai). شكرًا لصبرك أثناء الانتظار. لا نزال نكتشف محاولات الانتحال: لا تشارك في أي نشاط استثمار/تمويل باسم ZeroClaw إذا لم يتم نشره عبر قنواتنا الرسمية. | استخدم [هذا المستودع](https://github.com/zeroclaw-labs/zeroclaw) كمصدر وحيد للحقيقة. تابع [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)، [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs)، [Facebook (مجموعة)](https://www.facebook.com/groups/zeroclaw)، [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/)، و[Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) للتحديثات الرسمية. |
|
||||
| 2026-02-21 | _مهم_ | موقعنا الرسمي أصبح متاحًا الآن: [zeroclawlabs.ai](https://zeroclawlabs.ai). شكرًا لصبرك أثناء الانتظار. لا نزال نكتشف محاولات الانتحال: لا تشارك في أي نشاط استثمار/تمويل باسم ZeroClaw إذا لم يتم نشره عبر قنواتنا الرسمية. | استخدم [هذا المستودع](https://github.com/zeroclaw-labs/zeroclaw) كمصدر وحيد للحقيقة. تابع [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)، [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs)، [Facebook (مجموعة)](https://www.facebook.com/groups/zeroclawlabs)، [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/)، و[Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) للتحديثات الرسمية. |
|
||||
| 2026-02-19 | _مهم_ | قامت Anthropic بتحديث شروط استخدام المصادقة وبيانات الاعتماد في 2026-02-19. مصادقة OAuth (Free، Pro، Max) حصريًا لـ Claude Code و Claude.ai؛ استخدام رموز Claude Free/Pro/Max OAuth في أي منتج أو أداة أو خدمة أخرى (بما في ذلك Agent SDK) غير مسموح به وقد ينتهك شروط استخدام المستهلك. | يرجى تجنب مؤقتًا تكاملات Claude Code OAuth لمنع أي خسارة محتملة. البند الأصلي: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ الميزات
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # تدوير سر الاقتران الحالي
|
||||
zeroclaw tunnel start # بدء نفق إلى البرنامج الخفي المحلي
|
||||
zeroclaw tunnel stop # إيقاف النفق النشط
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# التشخيص
|
||||
zeroclaw doctor # تشغيل فحوصات صحة النظام
|
||||
zeroclaw version # عرض الإصدار ومعلومات البناء
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# চালান
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Docker দিয়ে
|
||||
@@ -177,7 +185,7 @@ channels:
|
||||
## কমিউনিটি
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Použijte tuto tabulku pro důležitá oznámení (změny kompatibility, bezpeč
|
||||
| Datum (UTC) | Úroveň | Oznámení | Akce |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritické_ | **Nejsme propojeni** s `openagen/zeroclaw` nebo `zeroclaw.org`. Doména `zeroclaw.org` aktuálně směřuje na fork `openagen/zeroclaw`, a tato doména/repoziťář se vydává za náš oficiální web/projekt. | Nevěřte informacím, binárním souborům, fundraisingu nebo oznámením z těchto zdrojů. Používejte pouze [tento repoziťář](https://github.com/zeroclaw-labs/zeroclaw) a naše ověřené sociální účty. |
|
||||
| 2026-02-21 | _Důležité_ | Náš oficiální web je nyní online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Děkujeme za trpělivost během čekání. Stále detekujeme pokusy o vydávání se: neúčastněte žádné investiční/fundraisingové aktivity ve jménu ZeroClaw pokud není publikována přes naše oficiální kanály. | Používejte [tento repoziťář](https://github.com/zeroclaw-labs/zeroclaw) jako jediný zdroj pravdy. Sledujte [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (skupina)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), a [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) pro oficiální aktualizace. |
|
||||
| 2026-02-21 | _Důležité_ | Náš oficiální web je nyní online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Děkujeme za trpělivost během čekání. Stále detekujeme pokusy o vydávání se: neúčastněte žádné investiční/fundraisingové aktivity ve jménu ZeroClaw pokud není publikována přes naše oficiální kanály. | Používejte [tento repoziťář](https://github.com/zeroclaw-labs/zeroclaw) jako jediný zdroj pravdy. Sledujte [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (skupina)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), a [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) pro oficiální aktualizace. |
|
||||
| 2026-02-19 | _Důležité_ | Anthropic aktualizoval podmínky použití autentizace a přihlašovacích údajů dne 2026-02-19. OAuth autentizace (Free, Pro, Max) je výhradně pro Claude Code a Claude.ai; použití Claude Free/Pro/Max OAuth tokenů v jakémkoliv jiném produktu, nástroji nebo službě (včetně Agent SDK) není povoleno a může porušit Podmínky použití spotřebitele. | Prosím dočasně se vyhněte Claude Code OAuth integracím pro předcházení potenciálním ztrátám. Původní klauzule: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funkce
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Rotuje existující párovací tajemství
|
||||
zeroclaw tunnel start # Spouští tunnel k lokálnímu daemon
|
||||
zeroclaw tunnel stop # Zastavuje aktivní tunnel
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnostika
|
||||
zeroclaw doctor # Spouští kontroly zdraví systému
|
||||
zeroclaw version # Zobrazuje verzi a build informace
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Kør
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Med Docker
|
||||
@@ -177,7 +185,7 @@ Se [LICENSE-APACHE](LICENSE-APACHE) og [LICENSE-MIT](LICENSE-MIT) for detaljer.
|
||||
## Fællesskab
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -107,7 +111,7 @@ Verwende diese Tabelle für wichtige Hinweise (Kompatibilitätsänderungen, Sich
|
||||
| Datum (UTC) | Ebene | Hinweis | Aktion |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritisch_ | Wir sind **nicht verbunden** mit `openagen/zeroclaw` oder `zeroclaw.org`. Die Domain `zeroclaw.org` zeigt derzeit auf den Fork `openagen/zeroclaw`, und diese Domain/Repository fälscht unsere offizielle Website/Projekt. | Vertraue keinen Informationen, Binärdateien, Fundraising oder Ankündigungen aus diesen Quellen. Verwende nur [dieses Repository](https://github.com/zeroclaw-labs/zeroclaw) und unsere verifizierten Social-Media-Konten. |
|
||||
| 2026-02-21 | _Wichtig_ | Unsere offizielle Website ist jetzt online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Danke für deine Geduld während der Wartezeit. Wir erkennen weiterhin Fälschungsversuche: nimm an keiner Investitions-/Finanzierungsaktivität im Namen von ZeroClaw teil, wenn sie nicht über unsere offiziellen Kanäle veröffentlicht wird. | Verwende [dieses Repository](https://github.com/zeroclaw-labs/zeroclaw) als einzige Quelle der Wahrheit. Folge [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (Gruppe)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), und [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) für offizielle Updates. |
|
||||
| 2026-02-21 | _Wichtig_ | Unsere offizielle Website ist jetzt online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Danke für deine Geduld während der Wartezeit. Wir erkennen weiterhin Fälschungsversuche: nimm an keiner Investitions-/Finanzierungsaktivität im Namen von ZeroClaw teil, wenn sie nicht über unsere offiziellen Kanäle veröffentlicht wird. | Verwende [dieses Repository](https://github.com/zeroclaw-labs/zeroclaw) als einzige Quelle der Wahrheit. Folge [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (Gruppe)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), und [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) für offizielle Updates. |
|
||||
| 2026-02-19 | _Wichtig_ | Anthropic hat die Nutzungsbedingungen für Authentifizierung und Anmeldedaten am 2026-02-19 aktualisiert. Die OAuth-Authentifizierung (Free, Pro, Max) ist ausschließlich für Claude Code und Claude.ai; die Verwendung von Claude Free/Pro/Max OAuth-Token in einem anderen Produkt, Tool oder Dienst (einschließlich Agent SDK) ist nicht erlaubt und kann gegen die Verbrauchernutzungsbedingungen verstoßen. | Bitte vermeide vorübergehend Claude Code OAuth-Integrationen, um potenzielle Verluste zu verhindern. Originalklausel: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funktionen
|
||||
@@ -370,6 +374,10 @@ zeroclaw pairing rotate # Rotiert das bestehende Pairing-Geheimnis
|
||||
zeroclaw tunnel start # Startet einen Tunnel zum lokalen Daemon
|
||||
zeroclaw tunnel stop # Stoppt den aktiven Tunnel
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnose
|
||||
zeroclaw doctor # Führt System-Gesundheitsprüfungen durch
|
||||
zeroclaw version # Zeigt Version und Build-Informationen
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -14,7 +14,11 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -100,6 +104,10 @@ cargo build --release
|
||||
|
||||
# Εκτέλεση
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Με Docker
|
||||
@@ -176,7 +184,7 @@ channels:
|
||||
## Κοινότητα
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Usa esta tabla para avisos importantes (cambios de compatibilidad, avisos de seg
|
||||
| Fecha (UTC) | Nivel | Aviso | Acción |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Crítico_ | **No estamos afiliados** con `openagen/zeroclaw` o `zeroclaw.org`. El dominio `zeroclaw.org` apunta actualmente al fork `openagen/zeroclaw`, y este dominio/repositorio está suplantando nuestro sitio web/proyecto oficial. | No confíes en información, binarios, recaudaciones de fondos o anuncios de estas fuentes. Usa solo [este repositorio](https://github.com/zeroclaw-labs/zeroclaw) y nuestras cuentas sociales verificadas. |
|
||||
| 2026-02-21 | _Importante_ | Nuestro sitio web oficial ahora está en línea: [zeroclawlabs.ai](https://zeroclawlabs.ai). Gracias por tu paciencia durante la espera. Todavía detectamos intentos de suplantación: no participes en ninguna actividad de inversión/financiamiento en nombre de ZeroClaw si no se publica a través de nuestros canales oficiales. | Usa [este repositorio](https://github.com/zeroclaw-labs/zeroclaw) como la única fuente de verdad. Sigue [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), y [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para actualizaciones oficiales. |
|
||||
| 2026-02-21 | _Importante_ | Nuestro sitio web oficial ahora está en línea: [zeroclawlabs.ai](https://zeroclawlabs.ai). Gracias por tu paciencia durante la espera. Todavía detectamos intentos de suplantación: no participes en ninguna actividad de inversión/financiamiento en nombre de ZeroClaw si no se publica a través de nuestros canales oficiales. | Usa [este repositorio](https://github.com/zeroclaw-labs/zeroclaw) como la única fuente de verdad. Sigue [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), y [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para actualizaciones oficiales. |
|
||||
| 2026-02-19 | _Importante_ | Anthropic actualizó los términos de uso de autenticación y credenciales el 2026-02-19. La autenticación OAuth (Free, Pro, Max) es exclusivamente para Claude Code y Claude.ai; el uso de tokens OAuth de Claude Free/Pro/Max en cualquier otro producto, herramienta o servicio (incluyendo Agent SDK) no está permitido y puede violar los Términos de Uso del Consumidor. | Por favor, evita temporalmente las integraciones OAuth de Claude Code para prevenir cualquier pérdida potencial. Cláusula original: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Características
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Rota el secreto de emparejamiento existente
|
||||
zeroclaw tunnel start # Inicia un tunnel hacia el daemon local
|
||||
zeroclaw tunnel stop # Detiene el tunnel activo
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnóstico
|
||||
zeroclaw doctor # Ejecuta verificaciones de salud del sistema
|
||||
zeroclaw version # Muestra versión e información de build
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Aja
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Dockerilla
|
||||
@@ -177,7 +185,7 @@ Katso [LICENSE-APACHE](LICENSE-APACHE) ja [LICENSE-MIT](LICENSE-MIT) yksityiskoh
|
||||
## Yhteisö
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -14,7 +14,11 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributeurs" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Offrez-moi un café" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -101,7 +105,7 @@ Utilisez ce tableau pour les avis importants (changements incompatibles, avis de
|
||||
| Date (UTC) | Niveau | Avis | Action |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Critique_ | Nous ne sommes **pas affiliés** à `openagen/zeroclaw` ou `zeroclaw.org`. Le domaine `zeroclaw.org` pointe actuellement vers le fork `openagen/zeroclaw`, et ce domaine/dépôt usurpe l'identité de notre site web/projet officiel. | Ne faites pas confiance aux informations, binaires, levées de fonds ou annonces provenant de ces sources. Utilisez uniquement [ce dépôt](https://github.com/zeroclaw-labs/zeroclaw) et nos comptes sociaux vérifiés. |
|
||||
| 2026-02-21 | _Important_ | Notre site officiel est désormais en ligne : [zeroclawlabs.ai](https://zeroclawlabs.ai). Merci pour votre patience pendant cette attente. Nous constatons toujours des tentatives d'usurpation : ne participez à aucune activité d'investissement/financement au nom de ZeroClaw si elle n'est pas publiée via nos canaux officiels. | Utilisez [ce dépôt](https://github.com/zeroclaw-labs/zeroclaw) comme source unique de vérité. Suivez [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (groupe)](https://www.facebook.com/groups/zeroclaw), et [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) pour les mises à jour officielles. |
|
||||
| 2026-02-21 | _Important_ | Notre site officiel est désormais en ligne : [zeroclawlabs.ai](https://zeroclawlabs.ai). Merci pour votre patience pendant cette attente. Nous constatons toujours des tentatives d'usurpation : ne participez à aucune activité d'investissement/financement au nom de ZeroClaw si elle n'est pas publiée via nos canaux officiels. | Utilisez [ce dépôt](https://github.com/zeroclaw-labs/zeroclaw) comme source unique de vérité. Suivez [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (groupe)](https://www.facebook.com/groups/zeroclawlabs), et [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) pour les mises à jour officielles. |
|
||||
| 2026-02-19 | _Important_ | Anthropic a mis à jour les conditions d'utilisation de l'authentification et des identifiants le 2026-02-19. L'authentification OAuth (Free, Pro, Max) est exclusivement destinée à Claude Code et Claude.ai ; l'utilisation de tokens OAuth de Claude Free/Pro/Max dans tout autre produit, outil ou service (y compris Agent SDK) n'est pas autorisée et peut violer les Conditions d'utilisation grand public. | Veuillez temporairement éviter les intégrations OAuth de Claude Code pour prévenir toute perte potentielle. Clause originale : [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Fonctionnalités
|
||||
@@ -364,6 +368,10 @@ zeroclaw pairing rotate # Fait tourner le secret de pairing existant
|
||||
zeroclaw tunnel start # Démarre un tunnel vers le daemon local
|
||||
zeroclaw tunnel stop # Arrête le tunnel actif
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnostic
|
||||
zeroclaw doctor # Exécute les vérifications de santé du système
|
||||
zeroclaw version # Affiche la version et les informations de build
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center" dir="rtl">
|
||||
@@ -107,6 +111,10 @@ cargo build --release
|
||||
|
||||
# הפעל
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### עם Docker
|
||||
@@ -193,7 +201,7 @@ channels:
|
||||
## קהילה
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# चलाएं
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Docker के साथ
|
||||
@@ -177,7 +185,7 @@ channels:
|
||||
## समुदाय
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Futtatás
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Docker-rel
|
||||
@@ -177,7 +185,7 @@ Részletekért lásd a [LICENSE-APACHE](LICENSE-APACHE) és [LICENSE-MIT](LICENS
|
||||
## Közösség
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Jalankan
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Dengan Docker
|
||||
@@ -177,7 +185,7 @@ Lihat [LICENSE-APACHE](LICENSE-APACHE) dan [LICENSE-MIT](LICENSE-MIT) untuk deta
|
||||
## Komunitas
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Usa questa tabella per avvisi importanti (cambiamenti di compatibilità, avvisi
|
||||
| Data (UTC) | Livello | Avviso | Azione |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Critico_ | **Non siamo affiliati** con `openagen/zeroclaw` o `zeroclaw.org`. Il dominio `zeroclaw.org` punta attualmente al fork `openagen/zeroclaw`, e questo dominio/repository sta contraffacendo il nostro sito web/progetto ufficiale. | Non fidarti di informazioni, binari, raccolte fondi o annunci da queste fonti. Usa solo [questo repository](https://github.com/zeroclaw-labs/zeroclaw) e i nostri account social verificati. |
|
||||
| 2026-02-21 | _Importante_ | Il nostro sito ufficiale è ora online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Grazie per la pazienza durante l'attesa. Rileviamo ancora tentativi di contraffazione: non partecipare ad alcuna attività di investimento/finanziamento a nome di ZeroClaw se non pubblicata tramite i nostri canali ufficiali. | Usa [questo repository](https://github.com/zeroclaw-labs/zeroclaw) come unica fonte di verità. Segui [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (gruppo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) per aggiornamenti ufficiali. |
|
||||
| 2026-02-21 | _Importante_ | Il nostro sito ufficiale è ora online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Grazie per la pazienza durante l'attesa. Rileviamo ancora tentativi di contraffazione: non partecipare ad alcuna attività di investimento/finanziamento a nome di ZeroClaw se non pubblicata tramite i nostri canali ufficiali. | Usa [questo repository](https://github.com/zeroclaw-labs/zeroclaw) come unica fonte di verità. Segui [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (gruppo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) per aggiornamenti ufficiali. |
|
||||
| 2026-02-19 | _Importante_ | Anthropic ha aggiornato i termini di utilizzo di autenticazione e credenziali il 2026-02-19. L'autenticazione OAuth (Free, Pro, Max) è esclusivamente per Claude Code e Claude.ai; l'uso di token OAuth di Claude Free/Pro/Max in qualsiasi altro prodotto, strumento o servizio (incluso Agent SDK) non è consentito e può violare i Termini di Utilizzo del Consumatore. | Si prega di evitare temporaneamente le integrazioni OAuth di Claude Code per prevenire qualsiasi potenziale perdita. Clausola originale: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funzionalità
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Ruota il segreto di pairing esistente
|
||||
zeroclaw tunnel start # Avvia un tunnel verso il daemon locale
|
||||
zeroclaw tunnel stop # Ferma il tunnel attivo
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnostica
|
||||
zeroclaw doctor # Esegue controlli di salute del sistema
|
||||
zeroclaw version # Mostra versione e informazioni di build
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀(日本語)</h1>
|
||||
@@ -13,7 +13,11 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +96,7 @@
|
||||
| 日付 (UTC) | レベル | お知らせ | 対応 |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _緊急_ | 私たちは `openagen/zeroclaw` および `zeroclaw.org` とは**一切関係ありません**。`zeroclaw.org` は現在 `openagen/zeroclaw` の fork を指しており、そのドメイン/リポジトリは当プロジェクトの公式サイト・公式プロジェクトを装っています。 | これらの情報源による案内、バイナリ、資金調達情報、公式発表は信頼しないでください。必ず[本リポジトリ](https://github.com/zeroclaw-labs/zeroclaw)と認証済み公式SNSのみを参照してください。 |
|
||||
| 2026-02-21 | _重要_ | 公式サイトを公開しました: [zeroclawlabs.ai](https://zeroclawlabs.ai)。公開までお待ちいただきありがとうございました。引き続きなりすましの試みを確認しているため、ZeroClaw 名義の投資・資金調達などの案内は、公式チャネルで確認できない限り参加しないでください。 | 情報は[本リポジトリ](https://github.com/zeroclaw-labs/zeroclaw)を最優先で確認し、[X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(グループ)](https://www.facebook.com/groups/zeroclaw)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) と [小紅書アカウント](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) で公式更新を確認してください。 |
|
||||
| 2026-02-21 | _重要_ | 公式サイトを公開しました: [zeroclawlabs.ai](https://zeroclawlabs.ai)。公開までお待ちいただきありがとうございました。引き続きなりすましの試みを確認しているため、ZeroClaw 名義の投資・資金調達などの案内は、公式チャネルで確認できない限り参加しないでください。 | 情報は[本リポジトリ](https://github.com/zeroclaw-labs/zeroclaw)を最優先で確認し、[X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(グループ)](https://www.facebook.com/groups/zeroclawlabs)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) と [小紅書アカウント](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) で公式更新を確認してください。 |
|
||||
| 2026-02-19 | _重要_ | Anthropic は 2026-02-19 に Authentication and Credential Use を更新しました。条文では、OAuth authentication(Free/Pro/Max)は Claude Code と Claude.ai 専用であり、Claude Free/Pro/Max で取得した OAuth トークンを他の製品・ツール・サービス(Agent SDK を含む)で使用することは許可されず、Consumer Terms of Service 違反に該当すると明記されています。 | 損失回避のため、当面は Claude Code OAuth 連携を試さないでください。原文: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)。 |
|
||||
|
||||
## 概要
|
||||
@@ -181,6 +185,10 @@ zeroclaw agent -m "Hello, ZeroClaw!"
|
||||
zeroclaw gateway
|
||||
|
||||
zeroclaw daemon
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
## Subscription Auth(OpenAI Codex / Claude Code)
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Harvard, MIT, 그리고 Sundai.Club 커뮤니티의 학생들과 멤버들이
|
||||
| 날짜 (UTC) | 수준 | 공지 | 조치 |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _중요_ | 우리는 `openagen/zeroclaw` 또는 `zeroclaw.org`와 **관련이 없습니다**. `zeroclaw.org` 도메인은 현재 `openagen/zeroclaw` 포크를 가리키고 있으며, 이 도메인/저장소는 우리의 공식 웹사이트/프로젝트를 사칭하고 있습니다. | 이 소스의 정보, 바이너리, 펀딩, 공지를 신뢰하지 마세요. [이 저장소](https://github.com/zeroclaw-labs/zeroclaw)와 우리의 확인된 소셜 계정만 사용하세요. |
|
||||
| 2026-02-21 | _중요_ | 우리의 공식 웹사이트가 이제 온라인입니다: [zeroclawlabs.ai](https://zeroclawlabs.ai). 기다려주셔서 감사합니다. 여전히 사칭 시도가 감지되고 있습니다: 공식 채널을 통해 게시되지 않은 ZeroClaw 이름의 모든 투자/펀딩 활동에 참여하지 마세요. | [이 저장소](https://github.com/zeroclaw-labs/zeroclaw)를 유일한 진실의 원천으로 사용하세요. [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (그룹)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), 그리고 [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)를 팔로우하여 공식 업데이트를 받으세요. |
|
||||
| 2026-02-21 | _중요_ | 우리의 공식 웹사이트가 이제 온라인입니다: [zeroclawlabs.ai](https://zeroclawlabs.ai). 기다려주셔서 감사합니다. 여전히 사칭 시도가 감지되고 있습니다: 공식 채널을 통해 게시되지 않은 ZeroClaw 이름의 모든 투자/펀딩 활동에 참여하지 마세요. | [이 저장소](https://github.com/zeroclaw-labs/zeroclaw)를 유일한 진실의 원천으로 사용하세요. [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (그룹)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), 그리고 [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)를 팔로우하여 공식 업데이트를 받으세요. |
|
||||
| 2026-02-19 | _중요_ | Anthropic이 2026-02-19에 인증 및 자격증명 사용 약관을 업데이트했습니다. OAuth 인증(Free, Pro, Max)은 Claude Code 및 Claude.ai 전용입니다. 다른 제품, 도구 또는 서비스(Agent SDK 포함)에서 Claude Free/Pro/Max OAuth 토큰을 사용하는 것은 허용되지 않으며 소비자 이용약관을 위반할 수 있습니다. | 잠재적인 손실을 방지하기 위해 일시적으로 Claude Code OAuth 통합을 피하세요. 원본 조항: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ 기능
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # 기존 페어링 시크릿 교체
|
||||
zeroclaw tunnel start # 로컬 데몬으로 터널 시작
|
||||
zeroclaw tunnel stop # 활성 터널 중지
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# 진단
|
||||
zeroclaw doctor # 시스템 상태 검사 실행
|
||||
zeroclaw version # 버전 및 빌드 정보 표시
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -14,7 +14,11 @@
|
||||
<a href="https://github.com/zeroclaw-labs/zeroclaw/graphs/contributors"><img src="https://img.shields.io/github/contributors/zeroclaw-labs/zeroclaw?color=green" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -94,7 +98,7 @@ Use this board for important notices (breaking changes, security advisories, mai
|
||||
| Date (UTC) | Level | Notice | Action |
|
||||
| ---------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Critical_ | We are **not affiliated** with `openagen/zeroclaw`, `zeroclaw.org` or `zeroclaw.net`. The `zeroclaw.org` and `zeroclaw.net` domains currently points to the `openagen/zeroclaw` fork, and that domain/repository are impersonating our official website/project. | Do not trust information, binaries, fundraising, or announcements from those sources. Use only [this repository](https://github.com/zeroclaw-labs/zeroclaw) and our verified social accounts. |
|
||||
| 2026-02-21 | _Important_ | Our official website is now live: [zeroclawlabs.ai](https://zeroclawlabs.ai). Thanks for your patience while we prepared the launch. We are still seeing impersonation attempts, so do **not** join any investment or fundraising activity claiming the ZeroClaw name unless it is published through our official channels. | Use [this repository](https://github.com/zeroclaw-labs/zeroclaw) as the single source of truth. Follow [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (Group)](https://www.facebook.com/groups/zeroclaw), and [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) for official updates. |
|
||||
| 2026-02-21 | _Important_ | Our official website is now live: [zeroclawlabs.ai](https://zeroclawlabs.ai). Thanks for your patience while we prepared the launch. We are still seeing impersonation attempts, so do **not** join any investment or fundraising activity claiming the ZeroClaw name unless it is published through our official channels. | Use [this repository](https://github.com/zeroclaw-labs/zeroclaw) as the single source of truth. Follow [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (Group)](https://www.facebook.com/groups/zeroclawlabs), and [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) for official updates. |
|
||||
| 2026-02-19 | _Important_ | Anthropic updated the Authentication and Credential Use terms on 2026-02-19. Claude Code OAuth tokens (Free, Pro, Max) are intended exclusively for Claude Code and Claude.ai; using OAuth tokens from Claude Free/Pro/Max in any other product, tool, or service (including Agent SDK) is not permitted and may violate the Consumer Terms of Service. | Please temporarily avoid Claude Code OAuth integrations to prevent potential loss. Original clause: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Features
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Kjør
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Med Docker
|
||||
@@ -177,7 +185,7 @@ Se [LICENSE-APACHE](LICENSE-APACHE) og [LICENSE-MIT](LICENSE-MIT) for detaljer.
|
||||
## Fellesskap
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Gebruik deze tabel voor belangrijke aankondigingen (compatibiliteitswijzigingen,
|
||||
| Datum (UTC) | Niveau | Aankondiging | Actie |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritiek_ | **We zijn niet gelieerd** met `openagen/zeroclaw` of `zeroclaw.org`. Het domein `zeroclaw.org` wijst momenteel naar de fork `openagen/zeroclaw`, en dit domein/repository imiteert onze officiële website/project. | Vertrouw geen informatie, binaire bestanden, fondsenwerving of aankondigingen van deze bronnen. Gebruik alleen [deze repository](https://github.com/zeroclaw-labs/zeroclaw) en onze geverifieerde sociale media accounts. |
|
||||
| 2026-02-21 | _Belangrijk_ | Onze officiële website is nu online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bedankt voor je geduld tijdens het wachten. We detecteren nog steeds imitatiepogingen: neem niet deel aan enige investering/fondsenwerving activiteit in naam van ZeroClaw als deze niet via onze officiële kanalen wordt gepubliceerd. | Gebruik [deze repository](https://github.com/zeroclaw-labs/zeroclaw) als de enige bron van waarheid. Volg [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (groep)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), en [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) voor officiële updates. |
|
||||
| 2026-02-21 | _Belangrijk_ | Onze officiële website is nu online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bedankt voor je geduld tijdens het wachten. We detecteren nog steeds imitatiepogingen: neem niet deel aan enige investering/fondsenwerving activiteit in naam van ZeroClaw als deze niet via onze officiële kanalen wordt gepubliceerd. | Gebruik [deze repository](https://github.com/zeroclaw-labs/zeroclaw) als de enige bron van waarheid. Volg [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (groep)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), en [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) voor officiële updates. |
|
||||
| 2026-02-19 | _Belangrijk_ | Anthropic heeft de gebruiksvoorwaarden voor authenticatie en inloggegevens bijgewerkt op 2026-02-19. OAuth authenticatie (Free, Pro, Max) is exclusief voor Claude Code en Claude.ai; het gebruik van Claude Free/Pro/Max OAuth tokens in enig ander product, tool of service (inclusief Agent SDK) is niet toegestaan en kan in strijd zijn met de Consumenten Gebruiksvoorwaarden. | Vermijd tijdelijk Claude Code OAuth integraties om potentiële verliezen te voorkomen. Originele clausule: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Functies
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Roteert het bestaande pairing geheim
|
||||
zeroclaw tunnel start # Start een tunnel naar de lokale daemon
|
||||
zeroclaw tunnel stop # Stopt de actieve tunnel
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnostiek
|
||||
zeroclaw doctor # Voert systeem gezondheidscontroles uit
|
||||
zeroclaw version # Toont versie en build informatie
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Użyj tej tabeli dla ważnych ogłoszeń (zmiany kompatybilności, powiadomienia
|
||||
| Data (UTC) | Poziom | Ogłoszenie | Działanie |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Krytyczny_ | **Nie jesteśmy powiązani** z `openagen/zeroclaw` lub `zeroclaw.org`. Domena `zeroclaw.org` obecnie wskazuje na fork `openagen/zeroclaw`, i ta domena/repozytorium podszywa się pod naszą oficjalną stronę/projekt. | Nie ufaj informacjom, plikom binarnym, zbiórkom funduszy lub ogłoszeniom z tych źródeł. Używaj tylko [tego repozytorium](https://github.com/zeroclaw-labs/zeroclaw) i naszych zweryfikowanych kont społecznościowych. |
|
||||
| 2026-02-21 | _Ważne_ | Nasza oficjalna strona jest teraz online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Dziękujemy za cierpliwość podczas oczekiwania. Nadal wykrywamy próby podszywania się: nie uczestnicz w żadnej działalności inwestycyjnej/finansowej w imieniu ZeroClaw jeśli nie jest opublikowana przez nasze oficjalne kanały. | Używaj [tego repozytorium](https://github.com/zeroclaw-labs/zeroclaw) jako jedynego źródła prawdy. Śledź [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupa)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), i [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) dla oficjalnych aktualizacji. |
|
||||
| 2026-02-21 | _Ważne_ | Nasza oficjalna strona jest teraz online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Dziękujemy za cierpliwość podczas oczekiwania. Nadal wykrywamy próby podszywania się: nie uczestnicz w żadnej działalności inwestycyjnej/finansowej w imieniu ZeroClaw jeśli nie jest opublikowana przez nasze oficjalne kanały. | Używaj [tego repozytorium](https://github.com/zeroclaw-labs/zeroclaw) jako jedynego źródła prawdy. Śledź [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupa)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), i [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) dla oficjalnych aktualizacji. |
|
||||
| 2026-02-19 | _Ważne_ | Anthropic zaktualizował warunki używania uwierzytelniania i poświadczeń 2026-02-19. Uwierzytelnianie OAuth (Free, Pro, Max) jest wyłącznie dla Claude Code i Claude.ai; używanie tokenów OAuth Claude Free/Pro/Max w jakimkolwiek innym produkcie, narzędziu lub usłudze (w tym Agent SDK) nie jest dozwolone i może naruszać Warunki Użytkowania Konsumenta. | Prosimy tymczasowo unikać integracji OAuth Claude Code aby zapobiec potencjalnym stratom. Oryginalna klauzula: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funkcje
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Rotuje istniejący sekret parowania
|
||||
zeroclaw tunnel start # Uruchamia tunnel do lokalnego daemon
|
||||
zeroclaw tunnel stop # Zatrzymuje aktywny tunnel
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnostyka
|
||||
zeroclaw doctor # Uruchamia sprawdzenia zdrowia systemu
|
||||
zeroclaw version # Pokazuje wersję i informacje o build
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Use esta tabela para avisos importantes (mudanças de compatibilidade, avisos de
|
||||
| Data (UTC) | Nível | Aviso | Ação |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Crítico_ | **Não somos afiliados** ao `openagen/zeroclaw` ou `zeroclaw.org`. O domínio `zeroclaw.org` atualmente aponta para o fork `openagen/zeroclaw`, e este domínio/repositório está falsificando nosso site/projeto oficial. | Não confie em informações, binários, arrecadações ou anúncios dessas fontes. Use apenas [este repositório](https://github.com/zeroclaw-labs/zeroclaw) e nossas contas sociais verificadas. |
|
||||
| 2026-02-21 | _Importante_ | Nosso site oficial agora está online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Obrigado pela paciência durante a espera. Ainda detectamos tentativas de falsificação: não participe de nenhuma atividade de investimento/financiamento em nome do ZeroClaw se não for publicada através de nossos canais oficiais. | Use [este repositório](https://github.com/zeroclaw-labs/zeroclaw) como a única fonte de verdade. Siga [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para atualizações oficiais. |
|
||||
| 2026-02-21 | _Importante_ | Nosso site oficial agora está online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Obrigado pela paciência durante a espera. Ainda detectamos tentativas de falsificação: não participe de nenhuma atividade de investimento/financiamento em nome do ZeroClaw se não for publicada através de nossos canais oficiais. | Use [este repositório](https://github.com/zeroclaw-labs/zeroclaw) como a única fonte de verdade. Siga [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), e [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para atualizações oficiais. |
|
||||
| 2026-02-19 | _Importante_ | A Anthropic atualizou os termos de uso de autenticação e credenciais em 2026-02-19. A autenticação OAuth (Free, Pro, Max) é exclusivamente para Claude Code e Claude.ai; o uso de tokens OAuth do Claude Free/Pro/Max em qualquer outro produto, ferramenta ou serviço (incluindo Agent SDK) não é permitido e pode violar os Termos de Uso do Consumidor. | Por favor, evite temporariamente as integrações OAuth do Claude Code para prevenir qualquer perda potencial. Cláusula original: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Funcionalidades
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Rotaciona o segredo de emparelhamento existente
|
||||
zeroclaw tunnel start # Inicia um tunnel para o daemon local
|
||||
zeroclaw tunnel stop # Para o tunnel ativo
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnóstico
|
||||
zeroclaw doctor # Executa verificações de saúde do sistema
|
||||
zeroclaw version # Mostra versão e informações de build
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Rulează
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Cu Docker
|
||||
@@ -177,7 +185,7 @@ Vezi [LICENSE-APACHE](LICENSE-APACHE) și [LICENSE-MIT](LICENSE-MIT) pentru deta
|
||||
## Comunitate
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀(Русский)</h1>
|
||||
@@ -13,7 +13,11 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +96,7 @@
|
||||
| Дата (UTC) | Уровень | Объявление | Действие |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _Срочно_ | Мы **не аффилированы** с `openagen/zeroclaw` и `zeroclaw.org`. Домен `zeroclaw.org` сейчас указывает на fork `openagen/zeroclaw`, и этот домен/репозиторий выдают себя за наш официальный сайт и проект. | Не доверяйте информации, бинарникам, сборам средств и «официальным» объявлениям из этих источников. Используйте только [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw) и наши верифицированные соцсети. |
|
||||
| 2026-02-21 | _Важно_ | Наш официальный сайт уже запущен: [zeroclawlabs.ai](https://zeroclawlabs.ai). Спасибо, что дождались запуска. При этом попытки выдавать себя за ZeroClaw продолжаются, поэтому не участвуйте в инвестициях, сборах средств и похожих активностях, если они не подтверждены через наши официальные каналы. | Ориентируйтесь только на [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw); также следите за [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (группа)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) и [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) для официальных обновлений. |
|
||||
| 2026-02-21 | _Важно_ | Наш официальный сайт уже запущен: [zeroclawlabs.ai](https://zeroclawlabs.ai). Спасибо, что дождались запуска. При этом попытки выдавать себя за ZeroClaw продолжаются, поэтому не участвуйте в инвестициях, сборах средств и похожих активностях, если они не подтверждены через наши официальные каналы. | Ориентируйтесь только на [этот репозиторий](https://github.com/zeroclaw-labs/zeroclaw); также следите за [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (группа)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) и [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) для официальных обновлений. |
|
||||
| 2026-02-19 | _Важно_ | Anthropic обновил раздел Authentication and Credential Use 2026-02-19. В нем указано, что OAuth authentication (Free/Pro/Max) предназначена только для Claude Code и Claude.ai; использование OAuth-токенов, полученных через Claude Free/Pro/Max, в любых других продуктах, инструментах или сервисах (включая Agent SDK), не допускается и может считаться нарушением Consumer Terms of Service. | Чтобы избежать потерь, временно не используйте Claude Code OAuth-интеграции. Оригинал: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
## О проекте
|
||||
@@ -181,6 +185,10 @@ zeroclaw agent -m "Hello, ZeroClaw!"
|
||||
zeroclaw gateway
|
||||
|
||||
zeroclaw daemon
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
## Subscription Auth (OpenAI Codex / Claude Code)
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Kör
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Med Docker
|
||||
@@ -177,7 +185,7 @@ Se [LICENSE-APACHE](LICENSE-APACHE) och [LICENSE-MIT](LICENSE-MIT) för detaljer
|
||||
## Community
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Run
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### ด้วย Docker
|
||||
@@ -177,7 +185,7 @@ channels:
|
||||
## ชุมชน
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Gamitin ang talahanayang ito para sa mahahalagang paunawa (compatibility changes
|
||||
| Petsa (UTC) | Antas | Paunawa | Aksyon |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritikal_ | **Hindi kami kaugnay** sa `openagen/zeroclaw` o `zeroclaw.org`. Ang domain na `zeroclaw.org` ay kasalukuyang tumuturo sa fork na `openagen/zeroclaw`, at ang domain/repository na ito ay nanggagaya sa aming opisyal na website/proyekto. | Huwag magtiwala sa impormasyon, binaries, fundraising, o mga anunsyo mula sa mga pinagmulang ito. Gamitin lamang [ang repository na ito](https://github.com/zeroclaw-labs/zeroclaw) at aming mga verified social media accounts. |
|
||||
| 2026-02-21 | _Mahalaga_ | Ang aming opisyal na website ay ngayon online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Salamat sa iyong pasensya sa panahon ng paghihintay. Nakikita pa rin namin ang mga pagtatangka ng panliliko: huwag lumahok sa anumang investment/funding activity sa ngalan ng ZeroClaw kung hindi ito nai-publish sa pamamagitan ng aming mga opisyal na channel. | Gamitin [ang repository na ito](https://github.com/zeroclaw-labs/zeroclaw) bilang nag-iisang source of truth. Sundan [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), at [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para sa mga opisyal na update. |
|
||||
| 2026-02-21 | _Mahalaga_ | Ang aming opisyal na website ay ngayon online: [zeroclawlabs.ai](https://zeroclawlabs.ai). Salamat sa iyong pasensya sa panahon ng paghihintay. Nakikita pa rin namin ang mga pagtatangka ng panliliko: huwag lumahok sa anumang investment/funding activity sa ngalan ng ZeroClaw kung hindi ito nai-publish sa pamamagitan ng aming mga opisyal na channel. | Gamitin [ang repository na ito](https://github.com/zeroclaw-labs/zeroclaw) bilang nag-iisang source of truth. Sundan [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grupo)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), at [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) para sa mga opisyal na update. |
|
||||
| 2026-02-19 | _Mahalaga_ | In-update ng Anthropic ang authentication at credential use terms noong 2026-02-19. Ang OAuth authentication (Free, Pro, Max) ay eksklusibo para sa Claude Code at Claude.ai; ang paggamit ng Claude Free/Pro/Max OAuth tokens sa anumang iba pang produkto, tool, o serbisyo (kasama ang Agent SDK) ay hindi pinapayagan at maaaring lumabag sa Consumer Terms of Use. | Mangyaring pansamantalang iwasan ang Claude Code OAuth integrations upang maiwasan ang anumang potensyal na pagkawala. Orihinal na clause: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Mga Tampok
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Nag-rotate ng existing pairing secret
|
||||
zeroclaw tunnel start # Nagse-start ng tunnel sa local daemon
|
||||
zeroclaw tunnel stop # Naghihinto sa active tunnel
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Diagnostics
|
||||
zeroclaw doctor # Nagpapatakbo ng system health checks
|
||||
zeroclaw version # Nagpapakita ng version at build info
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -103,7 +107,7 @@ Harvard, MIT ve Sundai.Club topluluklarının öğrencileri ve üyeleri tarafın
|
||||
| Tarih (UTC) | Seviye | Duyuru | Eylem |
|
||||
| ---------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-02-19 | _Kritik_ | **`openagen/zeroclaw` veya `zeroclaw.org` ile bağlantılı değiliz.** `zeroclaw.org` alanı şu anda `openagen/zeroclaw` fork'una işaret ediyor ve bu alan/depo taklitçiliğini yapıyor. | Bu kaynaklardan bilgi, ikili dosyalar, bağış toplama veya duyurulara güvenmeyin. Sadece [bu depoyu](https://github.com/zeroclaw-labs/zeroclaw) ve doğrulanmış sosyal medya hesaplarımızı kullanın. |
|
||||
| 2026-02-21 | _Önemli_ | Resmi web sitemiz artık çevrimiçi: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bekleme sürecinde sabırlarınız için teşekkürler. Hala taklit girişimleri tespit ediyoruz: ZeroClaw adına resmi kanallarımız aracılığıyla yayınlanmayan herhangi bir yatırım/bağış faaliyetine katılmayın. | [Bu depoyu](https://github.com/zeroclaw-labs/zeroclaw) tek doğruluk kaynağı olarak kullanın. Resmi güncellemeler için [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grup)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) ve [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)'u takip edin. |
|
||||
| 2026-02-21 | _Önemli_ | Resmi web sitemiz artık çevrimiçi: [zeroclawlabs.ai](https://zeroclawlabs.ai). Bekleme sürecinde sabırlarınız için teşekkürler. Hala taklit girişimleri tespit ediyoruz: ZeroClaw adına resmi kanallarımız aracılığıyla yayınlanmayan herhangi bir yatırım/bağış faaliyetine katılmayın. | [Bu depoyu](https://github.com/zeroclaw-labs/zeroclaw) tek doğruluk kaynağı olarak kullanın. Resmi güncellemeler için [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (grup)](https://www.facebook.com/groups/zeroclawlabs), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) ve [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search)'u takip edin. |
|
||||
| 2026-02-19 | _Önemli_ | Anthropic, 2026-02-19 tarihinde kimlik doğrulama ve kimlik bilgileri kullanım şartlarını güncelledi. OAuth kimlik doğrulaması (Free, Pro, Max) yalnızca Claude Code ve Claude.ai içindir; Claude Free/Pro/Max OAuth belirteçlerini başka herhangi bir ürün, araç veya hizmette (Agent SDK dahil) kullanmak yasaktır ve Tüketici Kullanım Şartlarını ihlal edebilir. | Olası kayıpları önlemek için lütfen geçici olarak Claude Code OAuth entegrasyonlarından kaçının. Orijinal madde: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Özellikler
|
||||
@@ -366,6 +370,10 @@ zeroclaw pairing rotate # Mevcut eşleştirme sırrını döndürür
|
||||
zeroclaw tunnel start # Yerel arka plan programına bir tünel başlatır
|
||||
zeroclaw tunnel stop # Aktif tüneli durdurur
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
|
||||
# Teşhis
|
||||
zeroclaw doctor # Sistem sağlık kontrollerini çalıştırır
|
||||
zeroclaw version # Sürüm ve derleme bilgilerini gösterir
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center">
|
||||
@@ -101,6 +105,10 @@ cargo build --release
|
||||
|
||||
# Запустіть
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### З Docker
|
||||
@@ -177,7 +185,7 @@ channels:
|
||||
## Спільнота
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -17,7 +17,11 @@
|
||||
<a href="https://zeroclawlabs.cn/group.jpg"><img src="https://img.shields.io/badge/WeChat-Group-B7D7A8?logo=wechat&logoColor=white" alt="WeChat Group" /></a>
|
||||
<a href="https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search"><img src="https://img.shields.io/badge/Xiaohongshu-Official-FF2442?style=flat" alt="Xiaohongshu: Official" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
|
||||
<p align="center" dir="rtl">
|
||||
@@ -107,6 +111,10 @@ cargo build --release
|
||||
|
||||
# چلائیں
|
||||
cargo run --release
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
### Docker کے ساتھ
|
||||
@@ -193,7 +201,7 @@ channels:
|
||||
## کمیونٹی
|
||||
|
||||
- [Telegram](https://t.me/zeroclawlabs)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclaw)
|
||||
- [Facebook Group](https://www.facebook.com/groups/zeroclawlabs)
|
||||
- [WeChat Group](https://zeroclawlabs.cn/group.jpg)
|
||||
|
||||
---
|
||||
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀</h1>
|
||||
@@ -14,7 +14,11 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
<p align="center">
|
||||
@@ -101,7 +105,7 @@ Bảng này dành cho các thông báo quan trọng (thay đổi không tương
|
||||
| Ngày (UTC) | Mức độ | Thông báo | Hành động |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _Nghiêm trọng_ | Chúng tôi **không có liên kết** với `openagen/zeroclaw` hoặc `zeroclaw.org`. Tên miền `zeroclaw.org` hiện đang trỏ đến fork `openagen/zeroclaw`, và tên miền/repository đó đang mạo danh website/dự án chính thức của chúng tôi. | Không tin tưởng thông tin, binary, gây quỹ, hay thông báo từ các nguồn đó. Chỉ sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) và các tài khoản mạng xã hội đã được xác minh của chúng tôi. |
|
||||
| 2026-02-21 | _Quan trọng_ | Website chính thức của chúng tôi đã ra mắt: [zeroclawlabs.ai](https://zeroclawlabs.ai). Cảm ơn mọi người đã kiên nhẫn chờ đợi. Chúng tôi vẫn đang ghi nhận các nỗ lực mạo danh, vì vậy **không** tham gia bất kỳ hoạt động đầu tư hoặc gây quỹ nào nhân danh ZeroClaw nếu thông tin đó không được công bố qua các kênh chính thức của chúng tôi. | Sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) làm nguồn thông tin duy nhất đáng tin cậy. Theo dõi [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclaw), và [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) để nhận cập nhật chính thức. |
|
||||
| 2026-02-21 | _Quan trọng_ | Website chính thức của chúng tôi đã ra mắt: [zeroclawlabs.ai](https://zeroclawlabs.ai). Cảm ơn mọi người đã kiên nhẫn chờ đợi. Chúng tôi vẫn đang ghi nhận các nỗ lực mạo danh, vì vậy **không** tham gia bất kỳ hoạt động đầu tư hoặc gây quỹ nào nhân danh ZeroClaw nếu thông tin đó không được công bố qua các kênh chính thức của chúng tôi. | Sử dụng [repository này](https://github.com/zeroclaw-labs/zeroclaw) làm nguồn thông tin duy nhất đáng tin cậy. Theo dõi [X (@zeroclawlabs)](https://x.com/zeroclawlabs?s=21), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclawlabs), và [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) để nhận cập nhật chính thức. |
|
||||
| 2026-02-19 | _Quan trọng_ | Anthropic đã cập nhật điều khoản Xác thực và Sử dụng Thông tin xác thực vào ngày 2026-02-19. Xác thực OAuth (Free, Pro, Max) được dành riêng cho Claude Code và Claude.ai; việc sử dụng OAuth token từ Claude Free/Pro/Max trong bất kỳ sản phẩm, công cụ hay dịch vụ nào khác (bao gồm Agent SDK) đều không được phép và có thể vi phạm Điều khoản Dịch vụ cho Người tiêu dùng. | Vui lòng tạm thời tránh tích hợp Claude Code OAuth để ngăn ngừa khả năng mất mát. Điều khoản gốc: [Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use). |
|
||||
|
||||
### ✨ Tính năng
|
||||
|
||||
+11
-3
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/zeroclaw.png" alt="ZeroClaw" width="200" />
|
||||
<img src="https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/docs/assets/zeroclaw-banner.png" alt="ZeroClaw" width="600" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">ZeroClaw 🦀(简体中文)</h1>
|
||||
@@ -13,7 +13,11 @@
|
||||
<a href="NOTICE"><img src="https://img.shields.io/badge/contributors-27+-green.svg" alt="Contributors" /></a>
|
||||
<a href="https://buymeacoffee.com/argenistherose"><img src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-Donate-yellow.svg?style=flat&logo=buy-me-a-coffee" alt="Buy Me a Coffee" /></a>
|
||||
<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/zeroclaw"><img src="https://img.shields.io/badge/Facebook-Group-1877F2?style=flat&logo=facebook&logoColor=white" alt="Facebook Group" /></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>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +96,7 @@
|
||||
| 日期(UTC) | 级别 | 通知 | 处理建议 |
|
||||
|---|---|---|---|
|
||||
| 2026-02-19 | _紧急_ | 我们与 `openagen/zeroclaw` 及 `zeroclaw.org` **没有任何关系**。`zeroclaw.org` 当前会指向 `openagen/zeroclaw` 这个 fork,并且该域名/仓库正在冒充我们的官网与官方项目。 | 请不要相信上述来源发布的任何信息、二进制、募资活动或官方声明。请仅以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)和已验证官方社媒为准。 |
|
||||
| 2026-02-21 | _重要_ | 我们的官网现已上线:[zeroclawlabs.ai](https://zeroclawlabs.ai)。感谢大家一直以来的耐心等待。我们仍在持续发现冒充行为,请勿参与任何未经我们官方渠道发布、但打着 ZeroClaw 名义进行的投资、募资或类似活动。 | 一切信息请以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)为准;也可关注 [X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(群组)](https://www.facebook.com/groups/zeroclaw)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) 与 [小红书账号](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 获取官方最新动态。 |
|
||||
| 2026-02-21 | _重要_ | 我们的官网现已上线:[zeroclawlabs.ai](https://zeroclawlabs.ai)。感谢大家一直以来的耐心等待。我们仍在持续发现冒充行为,请勿参与任何未经我们官方渠道发布、但打着 ZeroClaw 名义进行的投资、募资或类似活动。 | 一切信息请以[本仓库](https://github.com/zeroclaw-labs/zeroclaw)为准;也可关注 [X(@zeroclawlabs)](https://x.com/zeroclawlabs?s=21)、[Telegram(@zeroclawlabs)](https://t.me/zeroclawlabs)、[Facebook(群组)](https://www.facebook.com/groups/zeroclawlabs)、[Reddit(r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/) 与 [小红书账号](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 获取官方最新动态。 |
|
||||
| 2026-02-19 | _重要_ | Anthropic 于 2026-02-19 更新了 Authentication and Credential Use 条款。条款明确:OAuth authentication(用于 Free、Pro、Max)仅适用于 Claude Code 与 Claude.ai;将 Claude Free/Pro/Max 账号获得的 OAuth token 用于其他任何产品、工具或服务(包括 Agent SDK)不被允许,并可能构成对 Consumer Terms of Service 的违规。 | 为避免损失,请暂时不要尝试 Claude Code OAuth 集成;原文见:[Authentication and Credential Use](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)。 |
|
||||
|
||||
## 项目简介
|
||||
@@ -186,6 +190,10 @@ zeroclaw gateway
|
||||
|
||||
# 启动长期运行模式
|
||||
zeroclaw daemon
|
||||
|
||||
# Migrate from OpenClaw
|
||||
zeroclaw migrate openclaw --dry-run
|
||||
zeroclaw migrate openclaw
|
||||
```
|
||||
|
||||
## Subscription Auth(OpenAI Codex / Claude Code)
|
||||
|
||||
@@ -11,6 +11,7 @@ fn main() {
|
||||
println!("cargo:rerun-if-changed=web/src");
|
||||
println!("cargo:rerun-if-changed=web/public");
|
||||
println!("cargo:rerun-if-changed=web/index.html");
|
||||
println!("cargo:rerun-if-changed=docs/assets/zeroclaw-trans.png");
|
||||
println!("cargo:rerun-if-changed=web/package.json");
|
||||
println!("cargo:rerun-if-changed=web/package-lock.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.json");
|
||||
@@ -83,6 +84,7 @@ fn main() {
|
||||
}
|
||||
|
||||
ensure_dist_dir(dist_dir);
|
||||
ensure_dashboard_assets(dist_dir);
|
||||
}
|
||||
|
||||
fn web_build_required(web_dir: &Path, dist_dir: &Path) -> bool {
|
||||
@@ -136,6 +138,24 @@ fn ensure_dist_dir(dist_dir: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_dashboard_assets(dist_dir: &Path) {
|
||||
// The Rust gateway serves `web/dist/` via rust-embed under `/_app/*`.
|
||||
// Some builds may end up with missing/blank logo assets, so we ensure the
|
||||
// expected image is always present in `web/dist/` at compile time.
|
||||
let src = Path::new("docs/assets/zeroclaw-trans.png");
|
||||
if !src.exists() {
|
||||
eprintln!(
|
||||
"cargo:warning=docs/assets/zeroclaw-trans.png not found; skipping dashboard asset copy"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let dst = dist_dir.join("zeroclaw-trans.png");
|
||||
if let Err(e) = fs::copy(src, &dst) {
|
||||
eprintln!("cargo:warning=Failed to copy zeroclaw-trans.png into web/dist/: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the `npm` binary on the system PATH.
|
||||
fn which_npm() -> Result<String, ()> {
|
||||
let cmd = if cfg!(target_os = "windows") {
|
||||
|
||||
@@ -12,6 +12,13 @@ ignore = [
|
||||
# bincode v2.0.1 via probe-rs — project ceased but 1.3.3 considered complete
|
||||
"RUSTSEC-2025-0141",
|
||||
{ id = "RUSTSEC-2024-0384", reason = "Reported to `rust-nostr/nostr` and it's WIP" },
|
||||
{ id = "RUSTSEC-2024-0388", reason = "derivative via extism → wasmtime transitive dep" },
|
||||
{ id = "RUSTSEC-2025-0057", reason = "fxhash via extism → wasmtime transitive dep" },
|
||||
{ id = "RUSTSEC-2025-0119", reason = "number_prefix via indicatif — cosmetic dep" },
|
||||
# wasmtime vulns via extism 1.13.0 — no upstream fix yet; plugins feature-gated
|
||||
{ id = "RUSTSEC-2026-0006", reason = "wasmtime segfault via extism; awaiting extism upgrade" },
|
||||
{ id = "RUSTSEC-2026-0020", reason = "WASI resource exhaustion via extism; awaiting extism upgrade" },
|
||||
{ id = "RUSTSEC-2026-0021", reason = "WASI http fields panic via extism; awaiting extism upgrade" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 851 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
@@ -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 |
|
||||
@@ -183,6 +183,8 @@ Delegate sub-agent configurations. Each key under `[agents]` defines a named sub
|
||||
| `agentic` | `false` | Enable multi-turn tool-call loop mode for the sub-agent |
|
||||
| `allowed_tools` | `[]` | Tool allowlist for agentic mode |
|
||||
| `max_iterations` | `10` | Max tool-call iterations for agentic mode |
|
||||
| `timeout_secs` | `120` | Timeout in seconds for non-agentic provider calls (1–3600) |
|
||||
| `agentic_timeout_secs` | `300` | Timeout in seconds for agentic sub-agent loops (1–3600) |
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -199,11 +201,13 @@ max_depth = 2
|
||||
agentic = true
|
||||
allowed_tools = ["web_search", "http_request", "file_read"]
|
||||
max_iterations = 8
|
||||
agentic_timeout_secs = 600
|
||||
|
||||
[agents.coder]
|
||||
provider = "ollama"
|
||||
model = "qwen2.5-coder:32b"
|
||||
temperature = 0.2
|
||||
timeout_secs = 60
|
||||
```
|
||||
|
||||
## `[runtime]`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "zeroclaw-weather-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
extism-pdk = "1.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "weather"
|
||||
version = "0.1.0"
|
||||
description = "Example weather tool plugin for ZeroClaw"
|
||||
author = "ZeroClaw Labs"
|
||||
wasm_path = "target/wasm32-wasip1/release/zeroclaw_weather_plugin.wasm"
|
||||
|
||||
capabilities = ["tool"]
|
||||
permissions = ["http_client"]
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Example ZeroClaw weather plugin.
|
||||
//!
|
||||
//! Demonstrates how to create a WASM tool plugin using extism-pdk.
|
||||
//! Build with: cargo build --target wasm32-wasip1 --release
|
||||
|
||||
use extism_pdk::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WeatherInput {
|
||||
location: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WeatherOutput {
|
||||
location: String,
|
||||
temperature: f64,
|
||||
unit: String,
|
||||
condition: String,
|
||||
humidity: u32,
|
||||
}
|
||||
|
||||
/// Get weather for a location (mock implementation for demonstration).
|
||||
#[plugin_fn]
|
||||
pub fn get_weather(input: String) -> FnResult<String> {
|
||||
let params: WeatherInput =
|
||||
serde_json::from_str(&input).map_err(|e| Error::msg(format!("invalid input: {e}")))?;
|
||||
|
||||
// Mock weather data for demonstration
|
||||
let output = WeatherOutput {
|
||||
location: params.location,
|
||||
temperature: 22.5,
|
||||
unit: "celsius".to_string(),
|
||||
condition: "Partly cloudy".to_string(),
|
||||
humidity: 65,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&output)
|
||||
.map_err(|e| Error::msg(format!("serialization error: {e}")))?;
|
||||
|
||||
Ok(json)
|
||||
}
|
||||
@@ -1 +1,40 @@
|
||||
# Example Config
|
||||
|
||||
# ── Delegate Tool Configuration ─────────────────────────────────
|
||||
# Global default timeouts for the delegate tool.
|
||||
# These can be overridden per-agent in [agents.<name>] sections.
|
||||
[delegate]
|
||||
# Timeout in seconds for non-agentic sub-agent provider calls.
|
||||
# Default: 120
|
||||
timeout_secs = 120
|
||||
|
||||
# Timeout in seconds for agentic sub-agent runs (multi-turn tool loops).
|
||||
# Default: 300
|
||||
agentic_timeout_secs = 300
|
||||
|
||||
# ── Delegate Agent Configuration ────────────────────────────────
|
||||
# Define individual sub-agents that can be invoked via the delegate tool.
|
||||
# Each agent can override the global timeout values.
|
||||
[agents.researcher]
|
||||
provider = "openrouter"
|
||||
model = "anthropic/claude-sonnet-4"
|
||||
system_prompt = "You are a research assistant."
|
||||
temperature = 0.3
|
||||
max_depth = 3
|
||||
agentic = false
|
||||
max_iterations = 10
|
||||
# Optional: override global defaults
|
||||
timeout_secs = 120
|
||||
agentic_timeout_secs = 300
|
||||
|
||||
[agents.coder]
|
||||
provider = "ollama"
|
||||
model = "codellama"
|
||||
system_prompt = "You are a coding assistant."
|
||||
temperature = 0.2
|
||||
max_depth = 2
|
||||
agentic = true
|
||||
allowed_tools = ["read", "edit", "exec"]
|
||||
max_iterations = 15
|
||||
# Optional: use longer timeout for complex coding tasks
|
||||
agentic_timeout_secs = 600
|
||||
|
||||
+168
-28
@@ -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
|
||||
@@ -767,6 +753,140 @@ run_guided_installer() {
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_default_config_and_workspace() {
|
||||
# Creates a minimal config.toml and workspace scaffold files when the
|
||||
# onboard wizard was skipped (e.g. --skip-build --prefer-prebuilt, or
|
||||
# Docker mode without an API key).
|
||||
#
|
||||
# $1 — config directory (e.g. ~/.zeroclaw or $docker_data_dir/.zeroclaw)
|
||||
# $2 — workspace directory (e.g. ~/.zeroclaw/workspace or $docker_data_dir/workspace)
|
||||
# $3 — provider name (default: openrouter)
|
||||
local config_dir="$1"
|
||||
local workspace_dir="$2"
|
||||
local provider="${3:-openrouter}"
|
||||
|
||||
mkdir -p "$config_dir" "$workspace_dir"
|
||||
|
||||
# --- config.toml ---
|
||||
local config_path="$config_dir/config.toml"
|
||||
if [[ ! -f "$config_path" ]]; then
|
||||
step_dot "Creating default config.toml"
|
||||
cat > "$config_path" <<TOML
|
||||
# ZeroClaw configuration — generated by install.sh
|
||||
# Edit this file or run 'zeroclaw onboard' to reconfigure.
|
||||
|
||||
default_provider = "${provider}"
|
||||
workspace_dir = "${workspace_dir}"
|
||||
TOML
|
||||
if [[ -n "${API_KEY:-}" ]]; then
|
||||
printf 'api_key = "%s"\n' "$API_KEY" >> "$config_path"
|
||||
fi
|
||||
if [[ -n "${MODEL:-}" ]]; then
|
||||
printf 'default_model = "%s"\n' "$MODEL" >> "$config_path"
|
||||
fi
|
||||
chmod 600 "$config_path" 2>/dev/null || true
|
||||
step_ok "Default config.toml created at $config_path"
|
||||
else
|
||||
step_dot "config.toml already exists, skipping"
|
||||
fi
|
||||
|
||||
# --- Workspace scaffold ---
|
||||
local subdirs=(sessions memory state cron skills)
|
||||
for dir in "${subdirs[@]}"; do
|
||||
mkdir -p "$workspace_dir/$dir"
|
||||
done
|
||||
|
||||
# Seed workspace markdown files only if they don't already exist.
|
||||
local user_name="${USER:-User}"
|
||||
local agent_name="ZeroClaw"
|
||||
|
||||
_write_if_missing() {
|
||||
local filepath="$1"
|
||||
local content="$2"
|
||||
if [[ ! -f "$filepath" ]]; then
|
||||
printf '%s\n' "$content" > "$filepath"
|
||||
fi
|
||||
}
|
||||
|
||||
_write_if_missing "$workspace_dir/IDENTITY.md" \
|
||||
"# IDENTITY.md — Who Am I?
|
||||
|
||||
- **Name:** ${agent_name}
|
||||
- **Creature:** A Rust-forged AI — fast, lean, and relentless
|
||||
- **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.
|
||||
|
||||
---
|
||||
|
||||
Update this file as you evolve. Your identity is yours to shape."
|
||||
|
||||
_write_if_missing "$workspace_dir/USER.md" \
|
||||
"# USER.md — Who You're Helping
|
||||
|
||||
## About You
|
||||
- **Name:** ${user_name}
|
||||
- **Timezone:** UTC
|
||||
- **Languages:** English
|
||||
|
||||
## Preferences
|
||||
- (Add your preferences here)
|
||||
|
||||
## Work Context
|
||||
- (Add your work context here)
|
||||
|
||||
---
|
||||
*Update this anytime. The more ${agent_name} knows, the better it helps.*"
|
||||
|
||||
_write_if_missing "$workspace_dir/MEMORY.md" \
|
||||
"# MEMORY.md — Long-Term Memory
|
||||
|
||||
## Key Facts
|
||||
(Add important facts here)
|
||||
|
||||
## Decisions & Preferences
|
||||
(Record decisions and preferences here)
|
||||
|
||||
## Lessons Learned
|
||||
(Document mistakes and insights here)
|
||||
|
||||
## Open Loops
|
||||
(Track unfinished tasks and follow-ups here)"
|
||||
|
||||
_write_if_missing "$workspace_dir/AGENTS.md" \
|
||||
"# AGENTS.md — ${agent_name} Personal Assistant
|
||||
|
||||
## Every Session (required)
|
||||
|
||||
Before doing anything else:
|
||||
|
||||
1. Read SOUL.md — this is who you are
|
||||
2. Read USER.md — this is who you're helping
|
||||
3. Use memory_recall for recent context
|
||||
|
||||
---
|
||||
*Add your own conventions, style, and rules.*"
|
||||
|
||||
_write_if_missing "$workspace_dir/SOUL.md" \
|
||||
"# SOUL.md — Who You Are
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.**
|
||||
**Have opinions.** You're allowed to disagree.
|
||||
**Be resourceful before asking.** Try to figure it out first.
|
||||
**Earn trust through competence.**
|
||||
|
||||
## Identity
|
||||
|
||||
You are **${agent_name}**. Built in Rust. 3MB binary. Zero bloat.
|
||||
|
||||
---
|
||||
*This file is yours to evolve.*"
|
||||
|
||||
step_ok "Workspace scaffold ready at $workspace_dir"
|
||||
|
||||
unset -f _write_if_missing
|
||||
}
|
||||
|
||||
resolve_container_cli() {
|
||||
local requested_cli
|
||||
requested_cli="${ZEROCLAW_CONTAINER_CLI:-docker}"
|
||||
@@ -884,10 +1004,17 @@ run_docker_bootstrap() {
|
||||
-v "$config_mount" \
|
||||
-v "$workspace_mount" \
|
||||
"$docker_image" \
|
||||
"${onboard_cmd[@]}"
|
||||
"${onboard_cmd[@]}" || true
|
||||
else
|
||||
info "Docker image ready. Run zeroclaw onboard inside the container to configure."
|
||||
fi
|
||||
|
||||
# Ensure config.toml and workspace scaffold exist on the host even when
|
||||
# onboard was skipped, failed, or ran non-interactively inside the container.
|
||||
ensure_default_config_and_workspace \
|
||||
"$docker_data_dir/.zeroclaw" \
|
||||
"$docker_data_dir/workspace" \
|
||||
"$PROVIDER"
|
||||
}
|
||||
|
||||
SCRIPT_PATH="${BASH_SOURCE[0]:-$0}"
|
||||
@@ -1218,6 +1345,12 @@ if [[ -n "$TARGET_VERSION" ]]; then
|
||||
step_dot "Installing ZeroClaw v${TARGET_VERSION}"
|
||||
fi
|
||||
if [[ "$SKIP_BUILD" == false ]]; then
|
||||
# Clean stale build artifacts on upgrade to prevent bindgen/build-script
|
||||
# cache mismatches (e.g. libsqlite3-sys bindgen.rs not found).
|
||||
if [[ "$INSTALL_MODE" == "upgrade" && -d "$WORK_DIR/target/release/build" ]]; then
|
||||
step_dot "Cleaning stale build cache (upgrade detected)"
|
||||
cargo clean --release 2>/dev/null || true
|
||||
fi
|
||||
step_dot "Building release binary"
|
||||
cargo build --release --locked
|
||||
step_ok "Release binary built"
|
||||
@@ -1308,6 +1441,13 @@ elif [[ -z "$ZEROCLAW_BIN" ]]; then
|
||||
warn "ZeroClaw binary not found — cannot configure provider"
|
||||
fi
|
||||
|
||||
# Ensure config.toml and workspace scaffold exist even when onboard was
|
||||
# skipped, unavailable, or failed (e.g. --skip-build --prefer-prebuilt
|
||||
# without an API key, or when the binary could not run onboard).
|
||||
_native_config_dir="${ZEROCLAW_CONFIG_DIR:-$HOME/.zeroclaw}"
|
||||
_native_workspace_dir="${ZEROCLAW_WORKSPACE:-$_native_config_dir/workspace}"
|
||||
ensure_default_config_and_workspace "$_native_config_dir" "$_native_workspace_dir" "$PROVIDER"
|
||||
|
||||
# --- Gateway service management ---
|
||||
if [[ -n "$ZEROCLAW_BIN" ]]; then
|
||||
# Try to install and start the gateway service
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::agent::dispatcher::{
|
||||
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
|
||||
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
|
||||
use crate::config::Config;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::memory::{self, Memory, MemoryCategory};
|
||||
use crate::observability::{self, Observer, ObserverEvent};
|
||||
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
|
||||
@@ -40,6 +41,10 @@ pub struct Agent {
|
||||
route_model_by_hint: HashMap<String, String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
tool_descriptions: Option<ToolDescriptions>,
|
||||
/// Pre-rendered security policy summary injected into the system prompt
|
||||
/// so the LLM knows the concrete constraints before making tool calls.
|
||||
security_summary: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder {
|
||||
@@ -64,6 +69,8 @@ pub struct AgentBuilder {
|
||||
route_model_by_hint: Option<HashMap<String, String>>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
tool_descriptions: Option<ToolDescriptions>,
|
||||
security_summary: Option<String>,
|
||||
}
|
||||
|
||||
impl AgentBuilder {
|
||||
@@ -90,6 +97,8 @@ impl AgentBuilder {
|
||||
route_model_by_hint: None,
|
||||
allowed_tools: None,
|
||||
response_cache: None,
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +216,16 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn tool_descriptions(mut self, tool_descriptions: Option<ToolDescriptions>) -> Self {
|
||||
self.tool_descriptions = tool_descriptions;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn security_summary(mut self, summary: Option<String>) -> Self {
|
||||
self.security_summary = summary;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Agent> {
|
||||
let mut tools = self
|
||||
.tools
|
||||
@@ -257,6 +276,8 @@ impl AgentBuilder {
|
||||
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
|
||||
allowed_tools: allowed,
|
||||
response_cache: self.response_cache,
|
||||
tool_descriptions: self.tool_descriptions,
|
||||
security_summary: self.security_summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -416,6 +437,7 @@ impl Agent {
|
||||
))
|
||||
.skills_prompt_mode(config.skills.prompt_injection_mode)
|
||||
.auto_save(config.memory.auto_save)
|
||||
.security_summary(Some(security.prompt_summary()))
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -456,6 +478,8 @@ impl Agent {
|
||||
skills_prompt_mode: self.skills_prompt_mode,
|
||||
identity_config: Some(&self.identity_config),
|
||||
dispatcher_instructions: &instructions,
|
||||
tool_descriptions: self.tool_descriptions.as_ref(),
|
||||
security_summary: self.security_summary.clone(),
|
||||
};
|
||||
self.prompt_builder.build(&ctx)
|
||||
}
|
||||
|
||||
+741
-77
File diff suppressed because it is too large
Load Diff
+127
-3
@@ -1,4 +1,5 @@
|
||||
use crate::config::IdentityConfig;
|
||||
use crate::i18n::ToolDescriptions;
|
||||
use crate::identity;
|
||||
use crate::skills::Skill;
|
||||
use crate::tools::Tool;
|
||||
@@ -17,6 +18,14 @@ pub struct PromptContext<'a> {
|
||||
pub skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
|
||||
pub identity_config: Option<&'a IdentityConfig>,
|
||||
pub dispatcher_instructions: &'a str,
|
||||
/// Locale-aware tool descriptions. When present, tool descriptions in
|
||||
/// prompts are resolved from the locale file instead of hardcoded values.
|
||||
pub tool_descriptions: Option<&'a ToolDescriptions>,
|
||||
/// Pre-rendered security policy summary for inclusion in the Safety
|
||||
/// prompt section. When present, the LLM sees the concrete constraints
|
||||
/// (allowed commands, forbidden paths, autonomy level) so it can plan
|
||||
/// tool calls without trial-and-error. See issue #2404.
|
||||
pub security_summary: Option<String>,
|
||||
}
|
||||
|
||||
pub trait PromptSection: Send + Sync {
|
||||
@@ -34,6 +43,7 @@ impl SystemPromptBuilder {
|
||||
Self {
|
||||
sections: vec![
|
||||
Box::new(IdentitySection),
|
||||
Box::new(ToolHonestySection),
|
||||
Box::new(ToolsSection),
|
||||
Box::new(SafetySection),
|
||||
Box::new(SkillsSection),
|
||||
@@ -65,6 +75,7 @@ impl SystemPromptBuilder {
|
||||
}
|
||||
|
||||
pub struct IdentitySection;
|
||||
pub struct ToolHonestySection;
|
||||
pub struct ToolsSection;
|
||||
pub struct SafetySection;
|
||||
pub struct SkillsSection;
|
||||
@@ -116,6 +127,22 @@ impl PromptSection for IdentitySection {
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for ToolHonestySection {
|
||||
fn name(&self) -> &str {
|
||||
"tool_honesty"
|
||||
}
|
||||
|
||||
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
|
||||
Ok(
|
||||
"## CRITICAL: Tool Honesty\n\n\
|
||||
- NEVER fabricate, invent, or guess tool results. If a tool returns empty results, say \"No results found.\"\n\
|
||||
- If a tool call fails, report the error — never make up data to fill the gap.\n\
|
||||
- When unsure whether a tool call succeeded, ask the user rather than guessing."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptSection for ToolsSection {
|
||||
fn name(&self) -> &str {
|
||||
"tools"
|
||||
@@ -124,11 +151,15 @@ impl PromptSection for ToolsSection {
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::from("## Tools\n\n");
|
||||
for tool in ctx.tools {
|
||||
let desc = ctx
|
||||
.tool_descriptions
|
||||
.and_then(|td: &ToolDescriptions| td.get(tool.name()))
|
||||
.unwrap_or_else(|| tool.description());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
desc,
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
@@ -145,8 +176,25 @@ impl PromptSection for SafetySection {
|
||||
"safety"
|
||||
}
|
||||
|
||||
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
|
||||
Ok("## Safety\n\n- Do not exfiltrate private data.\n- Do not run destructive commands without asking.\n- Do not bypass oversight or approval mechanisms.\n- Prefer `trash` over `rm`.\n- When in doubt, ask before acting externally.".into())
|
||||
fn build(&self, ctx: &PromptContext<'_>) -> Result<String> {
|
||||
let mut out = String::from(
|
||||
"## Safety\n\n\
|
||||
- Do not exfiltrate private data.\n\
|
||||
- Do not run destructive commands without asking.\n\
|
||||
- Do not bypass oversight or approval mechanisms.\n\
|
||||
- Prefer `trash` over `rm`.\n\
|
||||
- When in doubt, ask before acting externally.",
|
||||
);
|
||||
|
||||
// Append concrete security policy constraints when available (#2404).
|
||||
// This tells the LLM exactly what commands are allowed, which paths
|
||||
// are off-limits, etc. — preventing wasteful trial-and-error.
|
||||
if let Some(ref summary) = ctx.security_summary {
|
||||
out.push_str("\n\n### Active Security Policy\n\n");
|
||||
out.push_str(summary);
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +365,8 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: Some(&identity_config),
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
|
||||
let section = IdentitySection;
|
||||
@@ -345,6 +395,8 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
assert!(prompt.contains("## Tools"));
|
||||
@@ -380,6 +432,8 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
|
||||
let output = SkillsSection.build(&ctx).unwrap();
|
||||
@@ -418,12 +472,15 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Compact,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
|
||||
let output = SkillsSection.build(&ctx).unwrap();
|
||||
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>"));
|
||||
}
|
||||
@@ -439,6 +496,8 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "instr",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
|
||||
let rendered = DateTimeSection.build(&ctx).unwrap();
|
||||
@@ -477,6 +536,8 @@ mod tests {
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
|
||||
let prompt = SystemPromptBuilder::with_defaults().build(&ctx).unwrap();
|
||||
@@ -493,4 +554,67 @@ mod tests {
|
||||
"<instruction>Use <tool_call> and & keep output "safe"</instruction>"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_section_includes_security_summary_when_present() {
|
||||
let tools: Vec<Box<dyn Tool>> = vec![];
|
||||
let summary = "**Autonomy level**: Supervised\n\
|
||||
**Allowed shell commands**: `git`, `ls`.\n"
|
||||
.to_string();
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
tools: &tools,
|
||||
skills: &[],
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
security_summary: Some(summary.clone()),
|
||||
};
|
||||
|
||||
let output = SafetySection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("## Safety"),
|
||||
"should contain base safety header"
|
||||
);
|
||||
assert!(
|
||||
output.contains("### Active Security Policy"),
|
||||
"should contain security policy header"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Autonomy level"),
|
||||
"should contain autonomy level from summary"
|
||||
);
|
||||
assert!(
|
||||
output.contains("`git`"),
|
||||
"should contain allowed commands from summary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_section_omits_security_policy_when_none() {
|
||||
let tools: Vec<Box<dyn Tool>> = vec![];
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
tools: &tools,
|
||||
skills: &[],
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode::Full,
|
||||
identity_config: None,
|
||||
dispatcher_instructions: "",
|
||||
tool_descriptions: None,
|
||||
security_summary: None,
|
||||
};
|
||||
|
||||
let output = SafetySection.build(&ctx).unwrap();
|
||||
assert!(
|
||||
output.contains("## Safety"),
|
||||
"should contain base safety header"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("### Active Security Policy"),
|
||||
"should NOT contain security policy header when None"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+925
-55
File diff suppressed because it is too large
Load Diff
+37
-1
@@ -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![])
|
||||
|
||||
+185
-7
@@ -332,6 +332,11 @@ pub struct TelegramChannel {
|
||||
transcription: Option<crate::config::TranscriptionConfig>,
|
||||
voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
ack_reactions: bool,
|
||||
tts_config: Option<crate::config::TtsConfig>,
|
||||
voice_chats: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
||||
pending_voice:
|
||||
Arc<std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -370,9 +375,19 @@ impl TelegramChannel {
|
||||
transcription: None,
|
||||
voice_transcriptions: Mutex::new(std::collections::HashMap::new()),
|
||||
workspace_dir: None,
|
||||
ack_reactions: true,
|
||||
tts_config: None,
|
||||
voice_chats: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
pending_voice: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure whether Telegram-native acknowledgement reactions are sent.
|
||||
pub fn with_ack_reactions(mut self, enabled: bool) -> Self {
|
||||
self.ack_reactions = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure workspace directory for saving downloaded attachments.
|
||||
pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
|
||||
self.workspace_dir = Some(dir);
|
||||
@@ -405,6 +420,14 @@ impl TelegramChannel {
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure text-to-speech for outgoing voice replies.
|
||||
pub fn with_tts(mut self, config: crate::config::TtsConfig) -> Self {
|
||||
if config.enabled {
|
||||
self.tts_config = Some(config);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Parse reply_target into (chat_id, optional thread_id).
|
||||
fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
|
||||
if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
|
||||
@@ -547,6 +570,51 @@ impl TelegramChannel {
|
||||
format!("{}/bot{}/{method}", self.api_base, self.bot_token)
|
||||
}
|
||||
|
||||
/// Synthesize text to speech and send as a Telegram voice note (static version for spawned tasks).
|
||||
async fn synthesize_and_send_voice(
|
||||
api_base: &str,
|
||||
bot_token: &str,
|
||||
chat_id: &str,
|
||||
thread_id: Option<&str>,
|
||||
text: &str,
|
||||
tts_config: &crate::config::TtsConfig,
|
||||
) -> anyhow::Result<()> {
|
||||
let tts_manager = super::tts::TtsManager::new(tts_config)?;
|
||||
let audio_bytes = tts_manager.synthesize(text).await?;
|
||||
let audio_len = audio_bytes.len();
|
||||
tracing::info!("Telegram TTS: synthesized {audio_len} bytes of audio");
|
||||
|
||||
if audio_bytes.is_empty() {
|
||||
anyhow::bail!("TTS returned empty audio");
|
||||
}
|
||||
|
||||
let url = format!("{api_base}/bot{bot_token}/sendVoice");
|
||||
let client = crate::config::build_runtime_proxy_client("channel.telegram");
|
||||
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("chat_id", chat_id.to_string())
|
||||
.part(
|
||||
"voice",
|
||||
reqwest::multipart::Part::bytes(audio_bytes)
|
||||
.file_name("voice.ogg")
|
||||
.mime_str("audio/ogg")?,
|
||||
);
|
||||
|
||||
if let Some(tid) = thread_id {
|
||||
form = form.text("message_thread_id", tid.to_string());
|
||||
}
|
||||
|
||||
let resp = client.post(&url).multipart(form).send().await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("sendVoice failed: status={status}, body={body}");
|
||||
}
|
||||
|
||||
tracing::info!("Telegram TTS: sent voice note ({audio_len} bytes)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn classify_edit_message_response(resp: reqwest::Response) -> EditMessageResult {
|
||||
if resp.status().is_success() {
|
||||
return EditMessageResult::Success;
|
||||
@@ -1165,6 +1233,11 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
return None;
|
||||
}
|
||||
|
||||
// Enter voice-chat mode so outgoing replies get a TTS voice note
|
||||
if let Ok(mut vc) = self.voice_chats.lock() {
|
||||
vc.insert(reply_target.clone());
|
||||
}
|
||||
|
||||
// Cache transcription for reply-context lookups
|
||||
{
|
||||
let mut cache = self.voice_transcriptions.lock();
|
||||
@@ -1336,6 +1409,11 @@ Allowlist Telegram username (without '@') or numeric user ID.",
|
||||
content
|
||||
};
|
||||
|
||||
// Exit voice-chat mode when user switches back to typing
|
||||
if let Ok(mut vc) = self.voice_chats.lock() {
|
||||
vc.remove(&reply_target);
|
||||
}
|
||||
|
||||
Some(ChannelMessage {
|
||||
id: format!("telegram_{chat_id}_{message_id}"),
|
||||
sender: sender_identity,
|
||||
@@ -2501,6 +2579,84 @@ impl Channel for TelegramChannel {
|
||||
None => (message.recipient.as_str(), None),
|
||||
};
|
||||
|
||||
// Voice chat mode: send text normally AND queue a voice note of the
|
||||
// final answer. Text in → text out. Voice in → text + voice out.
|
||||
let is_voice_chat = self
|
||||
.voice_chats
|
||||
.lock()
|
||||
.map(|vs| vs.contains(&message.recipient))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_voice_chat && self.tts_config.is_some() {
|
||||
// Only queue substantive natural-language replies for voice.
|
||||
// Skip tool outputs: URLs, JSON, code blocks, errors, short status.
|
||||
let is_substantive = content.len() > 40
|
||||
&& !content.starts_with("http")
|
||||
&& !content.starts_with('{')
|
||||
&& !content.starts_with('[')
|
||||
&& !content.starts_with("Error")
|
||||
&& !content.contains("```")
|
||||
&& !content.contains("tool_call")
|
||||
&& !content.contains("wttr.in");
|
||||
|
||||
if is_substantive {
|
||||
if let Ok(mut pv) = self.pending_voice.lock() {
|
||||
pv.insert(
|
||||
message.recipient.clone(),
|
||||
(content.clone(), std::time::Instant::now()),
|
||||
);
|
||||
}
|
||||
|
||||
let pending = self.pending_voice.clone();
|
||||
let voice_chats = self.voice_chats.clone();
|
||||
let api_base = self.api_base.clone();
|
||||
let bot_token = self.bot_token.clone();
|
||||
let chat_id_owned = chat_id.to_string();
|
||||
let thread_id_owned = thread_id.map(str::to_string);
|
||||
let recipient = message.recipient.clone();
|
||||
let tts_config = self.tts_config.clone().unwrap();
|
||||
tokio::spawn(async move {
|
||||
// Wait 10 seconds — long enough for the agent to finish its
|
||||
// full tool chain and send the final answer.
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
|
||||
|
||||
// Atomic check-and-remove: only one task gets the value
|
||||
let to_voice = pending.lock().ok().and_then(|mut pv| {
|
||||
if let Some((_, ts)) = pv.get(&recipient) {
|
||||
if ts.elapsed().as_secs() >= 8 {
|
||||
return pv.remove(&recipient).map(|(text, _)| text);
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
if let Some(text) = to_voice {
|
||||
if let Ok(mut vc) = voice_chats.lock() {
|
||||
vc.remove(&recipient);
|
||||
}
|
||||
match Self::synthesize_and_send_voice(
|
||||
&api_base,
|
||||
&bot_token,
|
||||
&chat_id_owned,
|
||||
thread_id_owned.as_deref(),
|
||||
&text,
|
||||
&tts_config,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!("Telegram: voice reply sent ({} chars)", text.len());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Telegram: TTS voice reply failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Always send text reply (voice chat gets both text and voice)
|
||||
let (text_without_markers, attachments) = parse_attachment_markers(&content);
|
||||
|
||||
if !attachments.is_empty() {
|
||||
@@ -2689,13 +2845,15 @@ Ensure only one `zeroclaw` process is using this bot token."
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some((reaction_chat_id, reaction_message_id)) =
|
||||
Self::extract_update_message_target(update)
|
||||
{
|
||||
self.try_add_ack_reaction_nonblocking(
|
||||
reaction_chat_id,
|
||||
reaction_message_id,
|
||||
);
|
||||
if self.ack_reactions {
|
||||
if let Some((reaction_chat_id, reaction_message_id)) =
|
||||
Self::extract_update_message_target(update)
|
||||
{
|
||||
self.try_add_ack_reaction_nonblocking(
|
||||
reaction_chat_id,
|
||||
reaction_message_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send "typing" indicator immediately when we receive a message
|
||||
@@ -4681,4 +4839,24 @@ mod tests {
|
||||
// the agent loop will return ProviderCapabilityError before calling
|
||||
// the provider, and the channel will send "⚠️ Error: ..." to the user.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ack_reactions_defaults_to_true() {
|
||||
let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
|
||||
assert!(ch.ack_reactions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_ack_reactions_false_disables_reactions() {
|
||||
let ch =
|
||||
TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(false);
|
||||
assert!(!ch.ack_reactions);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_ack_reactions_true_keeps_reactions() {
|
||||
let ch =
|
||||
TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(true);
|
||||
assert!(ch.ack_reactions);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-17
@@ -9,23 +9,24 @@ pub use schema::{
|
||||
AgentConfig, AssemblyAiSttConfig, AuditConfig, AutonomyConfig, BackupConfig,
|
||||
BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig, ChannelsConfig,
|
||||
ClassificationRule, CloudOpsConfig, ComposioConfig, Config, ConversationalAiConfig, CostConfig,
|
||||
CronConfig, DataRetentionConfig, DeepgramSttConfig, DelegateAgentConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig,
|
||||
FeishuConfig, GatewayConfig, GoogleSttConfig, GoogleTtsConfig, GoogleWorkspaceConfig,
|
||||
HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig,
|
||||
IMessageConfig, IdentityConfig, ImageProviderDalleConfig, ImageProviderFluxConfig,
|
||||
ImageProviderImagenConfig, ImageProviderStabilityConfig, KnowledgeConfig, LarkConfig,
|
||||
LinkedInConfig, LinkedInContentConfig, LinkedInImageConfig, MatrixConfig, McpConfig,
|
||||
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
|
||||
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
|
||||
ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig,
|
||||
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig,
|
||||
ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
|
||||
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
||||
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
|
||||
StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy,
|
||||
TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig,
|
||||
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
|
||||
CronConfig, DataRetentionConfig, DeepgramSttConfig, DelegateAgentConfig, DelegateToolConfig,
|
||||
DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig, EmbeddingRouteConfig,
|
||||
EstopConfig, FeishuConfig, GatewayConfig, GoogleSttConfig, GoogleTtsConfig,
|
||||
GoogleWorkspaceConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
|
||||
HttpRequestConfig, IMessageConfig, IdentityConfig, ImageProviderDalleConfig,
|
||||
ImageProviderFluxConfig, ImageProviderImagenConfig, ImageProviderStabilityConfig, JiraConfig,
|
||||
KnowledgeConfig, LarkConfig, LinkedInConfig, LinkedInContentConfig, LinkedInImageConfig,
|
||||
MatrixConfig, McpConfig, McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config,
|
||||
ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig,
|
||||
NotionConfig, ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig,
|
||||
OtpConfig, OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PluginsConfig,
|
||||
ProjectIntelConfig, ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig,
|
||||
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
|
||||
SchedulerConfig, SecretsConfig, SecurityConfig, SecurityOpsConfig, SkillCreationConfig,
|
||||
SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy, TelegramConfig,
|
||||
ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, TunnelConfig,
|
||||
WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
|
||||
};
|
||||
|
||||
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
@@ -54,6 +55,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
};
|
||||
|
||||
let discord = DiscordConfig {
|
||||
@@ -61,6 +63,7 @@ mod tests {
|
||||
guild_id: Some("123".into()),
|
||||
allowed_users: vec![],
|
||||
listen_to_bots: false,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
};
|
||||
|
||||
|
||||
+737
-33
File diff suppressed because it is too large
Load Diff
+148
-9
@@ -14,10 +14,13 @@ 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,
|
||||
Schedule, SessionTarget,
|
||||
};
|
||||
pub use types::{CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
|
||||
/// Validate a shell command against the full security policy (allowlist + risk gate).
|
||||
///
|
||||
@@ -153,6 +156,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
expression,
|
||||
tz,
|
||||
agent,
|
||||
allowed_tools,
|
||||
command,
|
||||
} => {
|
||||
let schedule = Schedule::Cron {
|
||||
@@ -169,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);
|
||||
@@ -183,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);
|
||||
@@ -198,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());
|
||||
@@ -213,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 };
|
||||
@@ -226,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}");
|
||||
@@ -243,6 +277,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
crate::CronCommands::Once {
|
||||
delay,
|
||||
agent,
|
||||
allowed_tools,
|
||||
command,
|
||||
} => {
|
||||
if agent {
|
||||
@@ -258,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());
|
||||
@@ -276,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 {
|
||||
@@ -301,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()
|
||||
};
|
||||
|
||||
@@ -427,6 +500,7 @@ mod tests {
|
||||
tz: tz.map(Into::into),
|
||||
command: command.map(Into::into),
|
||||
name: name.map(Into::into),
|
||||
allowed_tools: vec![],
|
||||
},
|
||||
config,
|
||||
)
|
||||
@@ -775,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,
|
||||
@@ -805,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,
|
||||
@@ -816,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();
|
||||
@@ -826,6 +964,7 @@ mod tests {
|
||||
expression: "*/5 * * * *".into(),
|
||||
tz: None,
|
||||
agent: false,
|
||||
allowed_tools: vec![],
|
||||
command: "echo ok".into(),
|
||||
},
|
||||
&config,
|
||||
|
||||
+146
-16
@@ -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
|
||||
@@ -242,6 +284,15 @@ async fn persist_job_result(
|
||||
if success {
|
||||
if let Err(e) = remove_job(config, &job.id) {
|
||||
tracing::warn!("Failed to remove one-shot cron job after success: {e}");
|
||||
// Fall back to disabling the job so it won't re-trigger.
|
||||
let _ = update_job(
|
||||
config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
enabled: Some(false),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let _ = record_last_run(config, &job.id, finished_at, false, output);
|
||||
@@ -497,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 {
|
||||
@@ -531,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::*;
|
||||
@@ -891,6 +965,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -916,6 +991,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -982,6 +1058,7 @@ mod tests {
|
||||
best_effort: false,
|
||||
}),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -1020,6 +1097,7 @@ mod tests {
|
||||
best_effort: true,
|
||||
}),
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let started = Utc::now();
|
||||
@@ -1038,7 +1116,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_not_deleted() {
|
||||
async fn persist_job_result_at_schedule_without_delete_after_run_is_disabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp).await;
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
@@ -1051,6 +1129,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!job.delete_after_run);
|
||||
@@ -1060,8 +1139,13 @@ mod tests {
|
||||
let success = persist_job_result(&config, &job, true, "ok", started, finished).await;
|
||||
assert!(success);
|
||||
|
||||
// After reschedule_after_run, At schedule jobs should be disabled
|
||||
// to prevent re-execution with a past next_run timestamp.
|
||||
let updated = cron::get_job(&config, &job.id).unwrap();
|
||||
assert!(updated.enabled);
|
||||
assert!(
|
||||
!updated.enabled,
|
||||
"At schedule job should be disabled after execution via reschedule"
|
||||
);
|
||||
assert_eq!(updated.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
@@ -1138,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");
|
||||
}
|
||||
}
|
||||
|
||||
+237
-25
@@ -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,
|
||||
],
|
||||
@@ -285,26 +322,41 @@ pub fn reschedule_after_run(
|
||||
output: &str,
|
||||
) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
let status = if success { "ok" } else { "error" };
|
||||
let bounded_output = truncate_cron_output(output);
|
||||
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
// One-shot `At` schedules have no future occurrence — record the run
|
||||
// result and disable the job so it won't be picked up again.
|
||||
if matches!(job.schedule, Schedule::At { .. }) {
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET enabled = 0, last_run = ?1, last_status = ?2, last_output = ?3
|
||||
WHERE id = ?4",
|
||||
params![now.to_rfc3339(), status, bounded_output, job.id],
|
||||
)
|
||||
.context("Failed to disable completed one-shot cron job")?;
|
||||
Ok(())
|
||||
})
|
||||
} else {
|
||||
let next_run = next_run_for_schedule(&job.schedule, now)?;
|
||||
with_connection(config, |conn| {
|
||||
conn.execute(
|
||||
"UPDATE cron_jobs
|
||||
SET next_run = ?1, last_run = ?2, last_status = ?3, last_output = ?4
|
||||
WHERE id = ?5",
|
||||
params![
|
||||
next_run.to_rfc3339(),
|
||||
now.to_rfc3339(),
|
||||
status,
|
||||
bounded_output,
|
||||
job.id
|
||||
],
|
||||
)
|
||||
.context("Failed to update cron job run state")?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_run(
|
||||
@@ -431,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)?,
|
||||
@@ -453,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)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -487,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([])?;
|
||||
@@ -542,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,
|
||||
@@ -575,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)
|
||||
}
|
||||
@@ -689,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();
|
||||
@@ -852,6 +1029,41 @@ mod tests {
|
||||
assert!(stored.len() <= MAX_CRON_OUTPUT_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, true, "done").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_disables_at_schedule_job_on_failure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = Utc::now() + ChronoDuration::minutes(10);
|
||||
let job = add_shell_job(&config, None, Schedule::At { at }, "echo once").unwrap();
|
||||
|
||||
reschedule_after_run(&config, &job, false, "failed").unwrap();
|
||||
|
||||
let stored = get_job(&config, &job.id).unwrap();
|
||||
assert!(
|
||||
!stored.enabled,
|
||||
"At schedule job should be disabled after reschedule even on failure"
|
||||
);
|
||||
assert_eq!(stored.last_status.as_deref(), Some("error"));
|
||||
assert_eq!(stored.last_output.as_deref(), Some("failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reschedule_after_run_truncates_last_output() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+66
-1
@@ -1,6 +1,32 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Try to deserialize a `serde_json::Value` as `T`. If the value is a JSON
|
||||
/// string that looks like an object (i.e. the LLM double-serialized it), parse
|
||||
/// the inner string first and then deserialize the resulting object. This
|
||||
/// provides backward-compatible handling for both `Value::Object` and
|
||||
/// `Value::String` representations.
|
||||
pub fn deserialize_maybe_stringified<T: serde::de::DeserializeOwned>(
|
||||
v: &serde_json::Value,
|
||||
) -> Result<T, serde_json::Error> {
|
||||
// Fast path: value is already the right shape (object, array, etc.)
|
||||
match serde_json::from_value::<T>(v.clone()) {
|
||||
Ok(parsed) => Ok(parsed),
|
||||
Err(first_err) => {
|
||||
// If it's a string, try parsing the string as JSON first.
|
||||
if let Some(s) = v.as_str() {
|
||||
let s = s.trim();
|
||||
if s.starts_with('{') || s.starts_with('[') {
|
||||
if let Ok(inner) = serde_json::from_str::<serde_json::Value>(s) {
|
||||
return serde_json::from_value::<T>(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(first_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum JobType {
|
||||
@@ -154,7 +180,46 @@ pub struct CronJobPatch {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::JobType;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_from_object() {
|
||||
let val = serde_json::json!({"kind": "cron", "expr": "*/5 * * * *"});
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_from_string() {
|
||||
let val = serde_json::Value::String(r#"{"kind":"cron","expr":"*/5 * * * *"}"#.to_string());
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Cron { ref expr, .. } if expr == "*/5 * * * *"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_schedule_string_with_tz() {
|
||||
let val = serde_json::Value::String(
|
||||
r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#.to_string(),
|
||||
);
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
match sched {
|
||||
Schedule::Cron { tz, .. } => assert_eq!(tz.as_deref(), Some("Asia/Shanghai")),
|
||||
_ => panic!("expected Cron variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_every_from_string() {
|
||||
let val = serde_json::Value::String(r#"{"kind":"every","every_ms":60000}"#.to_string());
|
||||
let sched = deserialize_maybe_stringified::<Schedule>(&val).unwrap();
|
||||
assert!(matches!(sched, Schedule::Every { every_ms: 60000 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_invalid_string_returns_error() {
|
||||
let val = serde_json::Value::String("not json at all".to_string());
|
||||
assert!(deserialize_maybe_stringified::<Schedule>(&val).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn job_type_try_from_accepts_known_values_case_insensitive() {
|
||||
|
||||
+8
-1
@@ -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),
|
||||
@@ -642,6 +645,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
assert!(has_supervised_channels(&config));
|
||||
}
|
||||
@@ -667,6 +671,7 @@ mod tests {
|
||||
allowed_users: vec!["*".into()],
|
||||
thread_replies: Some(true),
|
||||
mention_only: Some(false),
|
||||
interrupt_on_new_message: false,
|
||||
});
|
||||
assert!(has_supervised_channels(&config));
|
||||
}
|
||||
@@ -755,6 +760,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
|
||||
let target = resolve_heartbeat_delivery(&config).unwrap();
|
||||
@@ -771,6 +777,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
|
||||
let target = resolve_heartbeat_delivery(&config).unwrap();
|
||||
|
||||
@@ -1281,6 +1281,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
config.agents.insert(
|
||||
@@ -1295,6 +1297,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Plugin management API routes (requires `plugins-wasm` feature).
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
pub mod plugin_routes {
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Json},
|
||||
};
|
||||
|
||||
use super::super::AppState;
|
||||
|
||||
/// `GET /api/plugins` — list loaded plugins and their status.
|
||||
pub async fn list_plugins(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
// Auth check
|
||||
if state.pairing.require_pairing() {
|
||||
let token = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if !state.pairing.is_authenticated(token) {
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let config = state.config.lock();
|
||||
let plugins_enabled = config.plugins.enabled;
|
||||
let plugins_dir = config.plugins.plugins_dir.clone();
|
||||
drop(config);
|
||||
|
||||
let plugins: Vec<serde_json::Value> = if plugins_enabled {
|
||||
let plugin_path = if plugins_dir.starts_with("~/") {
|
||||
directories::UserDirs::new()
|
||||
.map(|u| u.home_dir().join(&plugins_dir[2..]))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from(&plugins_dir))
|
||||
} else {
|
||||
std::path::PathBuf::from(&plugins_dir)
|
||||
};
|
||||
|
||||
if plugin_path.exists() {
|
||||
match crate::plugins::host::PluginHost::new(
|
||||
plugin_path.parent().unwrap_or(&plugin_path),
|
||||
) {
|
||||
Ok(host) => host
|
||||
.list_plugins()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"name": p.name,
|
||||
"version": p.version,
|
||||
"description": p.description,
|
||||
"capabilities": p.capabilities,
|
||||
"loaded": p.loaded,
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
Json(serde_json::json!({
|
||||
"plugins_enabled": plugins_enabled,
|
||||
"plugins_dir": plugins_dir,
|
||||
"plugins": plugins,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
+31
-14
@@ -9,6 +9,8 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod api_pairing;
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
pub mod api_plugins;
|
||||
pub mod nodes;
|
||||
pub mod sse;
|
||||
pub mod static_files;
|
||||
@@ -631,6 +633,21 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
println!(" 🌐 Public URL: {url}");
|
||||
}
|
||||
println!(" 🌐 Web Dashboard: http://{display_addr}/");
|
||||
if let Some(code) = pairing.pairing_code() {
|
||||
println!();
|
||||
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
|
||||
println!(" ┌──────────────┐");
|
||||
println!(" │ {code} │");
|
||||
println!(" └──────────────┘");
|
||||
println!();
|
||||
} else if pairing.require_pairing() {
|
||||
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
|
||||
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
|
||||
println!();
|
||||
} else {
|
||||
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
|
||||
println!();
|
||||
}
|
||||
println!(" POST /pair — pair a new client (X-Pairing-Code header)");
|
||||
println!(" POST /webhook — {{\"message\": \"your prompt\"}}");
|
||||
if whatsapp_channel.is_some() {
|
||||
@@ -654,19 +671,6 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
}
|
||||
println!(" GET /health — health check");
|
||||
println!(" GET /metrics — Prometheus metrics");
|
||||
if let Some(code) = pairing.pairing_code() {
|
||||
println!();
|
||||
println!(" 🔐 PAIRING REQUIRED — use this one-time code:");
|
||||
println!(" ┌──────────────┐");
|
||||
println!(" │ {code} │");
|
||||
println!(" └──────────────┘");
|
||||
println!(" Send: POST /pair with header X-Pairing-Code: {code}");
|
||||
} else if pairing.require_pairing() {
|
||||
println!(" 🔒 Pairing: ACTIVE (bearer token required)");
|
||||
println!(" To pair a new device: zeroclaw gateway get-paircode --new");
|
||||
} else {
|
||||
println!(" ⚠️ Pairing: DISABLED (all requests accepted)");
|
||||
}
|
||||
println!(" Press Ctrl+C to stop.\n");
|
||||
|
||||
crate::health::mark_component_ok("gateway");
|
||||
@@ -762,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))
|
||||
@@ -789,7 +797,16 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route(
|
||||
"/api/devices/{id}/token/rotate",
|
||||
post(api_pairing::rotate_token),
|
||||
)
|
||||
);
|
||||
|
||||
// ── Plugin management API (requires plugins-wasm feature) ──
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
let app = app.route(
|
||||
"/api/plugins",
|
||||
get(api_plugins::plugin_routes::list_plugins),
|
||||
);
|
||||
|
||||
let app = app
|
||||
// ── SSE event stream ──
|
||||
.route("/api/events", get(sse::handle_sse_events))
|
||||
// ── WebSocket agent chat ──
|
||||
|
||||
+2
-1
@@ -236,7 +236,8 @@ async fn handle_socket(socket: WebSocket, state: AppState, session_id: Option<St
|
||||
let user_msg = crate::providers::ChatMessage::user(&content);
|
||||
let _ = backend.append(&session_key, &user_msg);
|
||||
}
|
||||
process_chat_message(&state, &mut agent, &mut sender, &content, &session_key).await;
|
||||
process_chat_message(&state, &mut agent, &mut sender, &content, &session_key)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
//! Internationalization support for tool descriptions.
|
||||
//!
|
||||
//! Loads tool descriptions from TOML locale files in `tool_descriptions/`.
|
||||
//! Falls back to English when a locale file or specific key is missing,
|
||||
//! and ultimately falls back to the hardcoded `tool.description()` value
|
||||
//! if no file-based description exists.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::debug;
|
||||
|
||||
/// Container for locale-specific tool descriptions loaded from TOML files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolDescriptions {
|
||||
/// Descriptions from the requested locale (may be empty if file missing).
|
||||
locale_descriptions: HashMap<String, String>,
|
||||
/// English fallback descriptions (always loaded when locale != "en").
|
||||
english_fallback: HashMap<String, String>,
|
||||
/// The resolved locale tag (e.g. "en", "zh-CN").
|
||||
locale: String,
|
||||
}
|
||||
|
||||
/// TOML structure: `[tools]` table mapping tool name -> description string.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct DescriptionFile {
|
||||
#[serde(default)]
|
||||
tools: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ToolDescriptions {
|
||||
/// Load descriptions for the given locale.
|
||||
///
|
||||
/// `search_dirs` lists directories to probe for `tool_descriptions/<locale>.toml`.
|
||||
/// The first directory containing a matching file wins.
|
||||
///
|
||||
/// Resolution:
|
||||
/// 1. Look up tool name in the locale file.
|
||||
/// 2. If missing (or locale file absent), look up in `en.toml`.
|
||||
/// 3. If still missing, callers fall back to `tool.description()`.
|
||||
pub fn load(locale: &str, search_dirs: &[PathBuf]) -> Self {
|
||||
let locale_descriptions = load_locale_file(locale, search_dirs);
|
||||
|
||||
let english_fallback = if locale == "en" {
|
||||
HashMap::new()
|
||||
} else {
|
||||
load_locale_file("en", search_dirs)
|
||||
};
|
||||
|
||||
debug!(
|
||||
locale = locale,
|
||||
locale_keys = locale_descriptions.len(),
|
||||
english_keys = english_fallback.len(),
|
||||
"tool descriptions loaded"
|
||||
);
|
||||
|
||||
Self {
|
||||
locale_descriptions,
|
||||
english_fallback,
|
||||
locale: locale.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the description for a tool by name.
|
||||
///
|
||||
/// Returns `Some(description)` if found in the locale file or English fallback.
|
||||
/// Returns `None` if neither file contains the key (caller should use hardcoded).
|
||||
pub fn get(&self, tool_name: &str) -> Option<&str> {
|
||||
self.locale_descriptions
|
||||
.get(tool_name)
|
||||
.or_else(|| self.english_fallback.get(tool_name))
|
||||
.map(String::as_str)
|
||||
}
|
||||
|
||||
/// The resolved locale tag.
|
||||
pub fn locale(&self) -> &str {
|
||||
&self.locale
|
||||
}
|
||||
|
||||
/// Create an empty instance that always returns `None` (hardcoded fallback).
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
locale_descriptions: HashMap::new(),
|
||||
english_fallback: HashMap::new(),
|
||||
locale: "en".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect the user's preferred locale from environment variables.
|
||||
///
|
||||
/// Checks `ZEROCLAW_LOCALE`, then `LANG`, then `LC_ALL`.
|
||||
/// Returns "en" if none are set or parseable.
|
||||
pub fn detect_locale() -> String {
|
||||
if let Ok(val) = std::env::var("ZEROCLAW_LOCALE") {
|
||||
let val = val.trim().to_string();
|
||||
if !val.is_empty() {
|
||||
return normalize_locale(&val);
|
||||
}
|
||||
}
|
||||
for var in &["LANG", "LC_ALL"] {
|
||||
if let Ok(val) = std::env::var(var) {
|
||||
let locale = normalize_locale(&val);
|
||||
if locale != "C" && locale != "POSIX" && !locale.is_empty() {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
}
|
||||
"en".to_string()
|
||||
}
|
||||
|
||||
/// Normalize a raw locale string (e.g. "zh_CN.UTF-8") to a tag we use
|
||||
/// for file lookup (e.g. "zh-CN").
|
||||
fn normalize_locale(raw: &str) -> String {
|
||||
// Strip encoding suffix (.UTF-8, .utf8, etc.)
|
||||
let base = raw.split('.').next().unwrap_or(raw);
|
||||
// Replace underscores with hyphens for BCP-47-ish consistency
|
||||
base.replace('_', "-")
|
||||
}
|
||||
|
||||
/// Build the default set of search directories for locale files.
|
||||
///
|
||||
/// 1. The workspace directory itself (for project-local overrides).
|
||||
/// 2. The binary's parent directory (for installed distributions).
|
||||
/// 3. The compile-time `CARGO_MANIFEST_DIR` as a final fallback during dev.
|
||||
pub fn default_search_dirs(workspace_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = vec![workspace_dir.to_path_buf()];
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
dirs.push(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// During development, also check the project root (where Cargo.toml lives).
|
||||
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
if !dirs.contains(&manifest_dir) {
|
||||
dirs.push(manifest_dir);
|
||||
}
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Try to load and parse a locale TOML file from the first matching search dir.
|
||||
fn load_locale_file(locale: &str, search_dirs: &[PathBuf]) -> HashMap<String, String> {
|
||||
let filename = format!("tool_descriptions/{locale}.toml");
|
||||
|
||||
for dir in search_dirs {
|
||||
let path = dir.join(&filename);
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(contents) => match toml::from_str::<DescriptionFile>(&contents) {
|
||||
Ok(parsed) => {
|
||||
debug!(path = %path.display(), keys = parsed.tools.len(), "loaded locale file");
|
||||
return parsed.tools;
|
||||
}
|
||||
Err(e) => {
|
||||
debug!(path = %path.display(), error = %e, "failed to parse locale file");
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
// File not found in this directory, try next.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug!(
|
||||
locale = locale,
|
||||
"no locale file found in any search directory"
|
||||
);
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// Helper: create a temp dir with a `tool_descriptions/<locale>.toml` file.
|
||||
fn write_locale_file(dir: &Path, locale: &str, content: &str) {
|
||||
let td = dir.join("tool_descriptions");
|
||||
fs::create_dir_all(&td).unwrap();
|
||||
fs::write(td.join(format!("{locale}.toml")), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_english_descriptions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
file_read = "Read file contents"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
|
||||
assert_eq!(descs.get("file_read"), Some("Read file contents"));
|
||||
assert_eq!(descs.get("nonexistent"), None);
|
||||
assert_eq!(descs.locale(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_english_when_locale_key_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
file_read = "Read file contents"
|
||||
"#,
|
||||
);
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"zh-CN",
|
||||
r#"[tools]
|
||||
shell = "在工作区目录中执行 shell 命令"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("zh-CN", &[tmp.path().to_path_buf()]);
|
||||
// Translated key returns Chinese.
|
||||
assert_eq!(descs.get("shell"), Some("在工作区目录中执行 shell 命令"));
|
||||
// Missing key falls back to English.
|
||||
assert_eq!(descs.get("file_read"), Some("Read file contents"));
|
||||
assert_eq!(descs.locale(), "zh-CN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_locale_file_missing() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"en",
|
||||
r#"[tools]
|
||||
shell = "Execute a shell command"
|
||||
"#,
|
||||
);
|
||||
// Request a locale that has no file.
|
||||
let descs = ToolDescriptions::load("fr", &[tmp.path().to_path_buf()]);
|
||||
// Falls back to English.
|
||||
assert_eq!(descs.get("shell"), Some("Execute a shell command"));
|
||||
assert_eq!(descs.locale(), "fr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_when_no_files_exist() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let descs = ToolDescriptions::load("en", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(descs.get("shell"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_always_returns_none() {
|
||||
let descs = ToolDescriptions::empty();
|
||||
assert_eq!(descs.get("shell"), None);
|
||||
assert_eq!(descs.locale(), "en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_locale_from_env() {
|
||||
// Save and restore env.
|
||||
let saved = std::env::var("ZEROCLAW_LOCALE").ok();
|
||||
let saved_lang = std::env::var("LANG").ok();
|
||||
|
||||
std::env::set_var("ZEROCLAW_LOCALE", "ja-JP");
|
||||
assert_eq!(detect_locale(), "ja-JP");
|
||||
|
||||
std::env::remove_var("ZEROCLAW_LOCALE");
|
||||
std::env::set_var("LANG", "zh_CN.UTF-8");
|
||||
assert_eq!(detect_locale(), "zh-CN");
|
||||
|
||||
// Restore.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("ZEROCLAW_LOCALE", v),
|
||||
None => std::env::remove_var("ZEROCLAW_LOCALE"),
|
||||
}
|
||||
match saved_lang {
|
||||
Some(v) => std::env::set_var("LANG", v),
|
||||
None => std::env::remove_var("LANG"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_locale_strips_encoding() {
|
||||
assert_eq!(normalize_locale("en_US.UTF-8"), "en-US");
|
||||
assert_eq!(normalize_locale("zh_CN.utf8"), "zh-CN");
|
||||
assert_eq!(normalize_locale("fr"), "fr");
|
||||
assert_eq!(normalize_locale("pt_BR"), "pt-BR");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_locale_overrides_env() {
|
||||
// This tests the precedence logic: if config provides a locale,
|
||||
// it should be used instead of detect_locale().
|
||||
// The actual override happens at the call site in prompt.rs / loop_.rs,
|
||||
// so here we just verify ToolDescriptions works with an explicit locale.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
write_locale_file(
|
||||
tmp.path(),
|
||||
"de",
|
||||
r#"[tools]
|
||||
shell = "Einen Shell-Befehl im Arbeitsverzeichnis ausführen"
|
||||
"#,
|
||||
);
|
||||
let descs = ToolDescriptions::load("de", &[tmp.path().to_path_buf()]);
|
||||
assert_eq!(
|
||||
descs.get("shell"),
|
||||
Some("Einen Shell-Befehl im Arbeitsverzeichnis ausführen")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -840,6 +840,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
let entries = all_integrations();
|
||||
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap();
|
||||
|
||||
+19
@@ -54,6 +54,7 @@ pub(crate) mod hardware;
|
||||
pub(crate) mod health;
|
||||
pub(crate) mod heartbeat;
|
||||
pub mod hooks;
|
||||
pub mod i18n;
|
||||
pub(crate) mod identity;
|
||||
pub(crate) mod integrations;
|
||||
pub mod memory;
|
||||
@@ -73,6 +74,9 @@ pub mod tools;
|
||||
pub(crate) mod tunnel;
|
||||
pub(crate) mod util;
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
pub mod plugins;
|
||||
|
||||
pub use config::Config;
|
||||
|
||||
/// Gateway management subcommands
|
||||
@@ -295,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,
|
||||
},
|
||||
@@ -313,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,
|
||||
},
|
||||
@@ -331,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,
|
||||
},
|
||||
@@ -351,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,
|
||||
},
|
||||
@@ -384,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 {
|
||||
|
||||
+82
@@ -89,6 +89,7 @@ mod hardware;
|
||||
mod health;
|
||||
mod heartbeat;
|
||||
mod hooks;
|
||||
mod i18n;
|
||||
mod identity;
|
||||
mod integrations;
|
||||
mod memory;
|
||||
@@ -97,6 +98,8 @@ mod multimodal;
|
||||
mod observability;
|
||||
mod onboard;
|
||||
mod peripherals;
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
mod plugins;
|
||||
mod providers;
|
||||
mod runtime;
|
||||
mod security;
|
||||
@@ -528,6 +531,35 @@ Examples:
|
||||
#[arg(value_enum)]
|
||||
shell: CompletionShell,
|
||||
},
|
||||
|
||||
/// Manage WASM plugins
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
Plugin {
|
||||
#[command(subcommand)]
|
||||
plugin_command: PluginCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum PluginCommands {
|
||||
/// List installed plugins
|
||||
List,
|
||||
/// Install a plugin from a directory or URL
|
||||
Install {
|
||||
/// Path to plugin directory or manifest
|
||||
source: String,
|
||||
},
|
||||
/// Remove an installed plugin
|
||||
Remove {
|
||||
/// Plugin name
|
||||
name: String,
|
||||
},
|
||||
/// Show information about a plugin
|
||||
Info {
|
||||
/// Plugin name
|
||||
name: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
@@ -1325,6 +1357,56 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
|
||||
#[cfg(feature = "plugins-wasm")]
|
||||
Commands::Plugin { plugin_command } => match plugin_command {
|
||||
PluginCommands::List => {
|
||||
let host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
let plugins = host.list_plugins();
|
||||
if plugins.is_empty() {
|
||||
println!("No plugins installed.");
|
||||
} else {
|
||||
println!("Installed plugins:");
|
||||
for p in &plugins {
|
||||
println!(
|
||||
" {} v{} — {}",
|
||||
p.name,
|
||||
p.version,
|
||||
p.description.as_deref().unwrap_or("(no description)")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
PluginCommands::Install { source } => {
|
||||
let mut host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
host.install(&source)?;
|
||||
println!("Plugin installed from {source}");
|
||||
Ok(())
|
||||
}
|
||||
PluginCommands::Remove { name } => {
|
||||
let mut host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
host.remove(&name)?;
|
||||
println!("Plugin '{name}' removed.");
|
||||
Ok(())
|
||||
}
|
||||
PluginCommands::Info { name } => {
|
||||
let host = zeroclaw::plugins::host::PluginHost::new(&config.workspace_dir)?;
|
||||
match host.get_plugin(&name) {
|
||||
Some(info) => {
|
||||
println!("Plugin: {} v{}", info.name, info.version);
|
||||
if let Some(desc) = &info.description {
|
||||
println!("Description: {desc}");
|
||||
}
|
||||
println!("Capabilities: {:?}", info.capabilities);
|
||||
println!("Permissions: {:?}", info.permissions);
|
||||
println!("WASM: {}", info.wasm_path.display());
|
||||
}
|
||||
None => println!("Plugin '{name}' not found."),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
));
|
||||
|
||||
@@ -210,7 +210,9 @@ impl Observer for OtelObserver {
|
||||
}
|
||||
ObserverEvent::LlmRequest { .. }
|
||||
| ObserverEvent::ToolCallStart { .. }
|
||||
| ObserverEvent::TurnComplete => {}
|
||||
| ObserverEvent::TurnComplete
|
||||
| ObserverEvent::CacheHit { .. }
|
||||
| ObserverEvent::CacheMiss { .. } => {}
|
||||
ObserverEvent::LlmResponse {
|
||||
provider,
|
||||
model,
|
||||
|
||||
+110
-1
@@ -178,6 +178,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
delegate: crate::config::DelegateToolConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
swarms: std::collections::HashMap::new(),
|
||||
hooks: crate::config::HooksConfig::default(),
|
||||
@@ -189,9 +190,12 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
nodes: crate::config::NodesConfig::default(),
|
||||
workspace: crate::config::WorkspaceConfig::default(),
|
||||
notion: crate::config::NotionConfig::default(),
|
||||
jira: crate::config::JiraConfig::default(),
|
||||
node_transport: crate::config::NodeTransportConfig::default(),
|
||||
knowledge: crate::config::KnowledgeConfig::default(),
|
||||
linkedin: crate::config::LinkedInConfig::default(),
|
||||
plugins: crate::config::PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
println!(
|
||||
@@ -461,6 +465,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>,
|
||||
@@ -551,6 +596,7 @@ async fn run_quick_setup_with_home(
|
||||
identity: crate::config::IdentityConfig::default(),
|
||||
cost: crate::config::CostConfig::default(),
|
||||
peripherals: crate::config::PeripheralsConfig::default(),
|
||||
delegate: crate::config::DelegateToolConfig::default(),
|
||||
agents: std::collections::HashMap::new(),
|
||||
swarms: std::collections::HashMap::new(),
|
||||
hooks: crate::config::HooksConfig::default(),
|
||||
@@ -562,9 +608,12 @@ async fn run_quick_setup_with_home(
|
||||
nodes: crate::config::NodesConfig::default(),
|
||||
workspace: crate::config::WorkspaceConfig::default(),
|
||||
notion: crate::config::NotionConfig::default(),
|
||||
jira: crate::config::JiraConfig::default(),
|
||||
node_transport: crate::config::NodeTransportConfig::default(),
|
||||
knowledge: crate::config::KnowledgeConfig::default(),
|
||||
linkedin: crate::config::LinkedInConfig::default(),
|
||||
plugins: crate::config::PluginsConfig::default(),
|
||||
locale: None,
|
||||
};
|
||||
|
||||
config.save().await?;
|
||||
@@ -646,6 +695,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() {
|
||||
@@ -3681,6 +3740,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_reactions: None,
|
||||
});
|
||||
}
|
||||
ChannelMenuChoice::Discord => {
|
||||
@@ -3779,6 +3839,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
guild_id: if guild.is_empty() { None } else { Some(guild) },
|
||||
allowed_users,
|
||||
listen_to_bots: false,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
});
|
||||
}
|
||||
@@ -3908,6 +3969,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
},
|
||||
allowed_users,
|
||||
interrupt_on_new_message: false,
|
||||
thread_replies: None,
|
||||
mention_only: false,
|
||||
});
|
||||
}
|
||||
@@ -5362,7 +5424,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\
|
||||
@@ -6061,6 +6123,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]
|
||||
@@ -7253,6 +7361,7 @@ mod tests {
|
||||
allowed_users: vec!["*".into()],
|
||||
thread_replies: Some(true),
|
||||
mention_only: Some(false),
|
||||
interrupt_on_new_message: false,
|
||||
});
|
||||
assert!(has_launchable_channels(&channels));
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Plugin error types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PluginError {
|
||||
#[error("plugin not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("invalid manifest: {0}")]
|
||||
InvalidManifest(String),
|
||||
|
||||
#[error("failed to load WASM module: {0}")]
|
||||
LoadFailed(String),
|
||||
|
||||
#[error("plugin execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
|
||||
#[error("permission denied: plugin '{plugin}' requires '{permission}'")]
|
||||
PermissionDenied { plugin: String, permission: String },
|
||||
|
||||
#[error("plugin '{0}' is already loaded")]
|
||||
AlreadyLoaded(String),
|
||||
|
||||
#[error("plugin capability not supported: {0}")]
|
||||
UnsupportedCapability(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("TOML parse error: {0}")]
|
||||
TomlParse(#[from] toml::de::Error),
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
//! Plugin host: discovery, loading, lifecycle management.
|
||||
|
||||
use super::error::PluginError;
|
||||
use super::{PluginCapability, PluginInfo, PluginManifest};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Manages the lifecycle of WASM plugins.
|
||||
pub struct PluginHost {
|
||||
plugins_dir: PathBuf,
|
||||
loaded: HashMap<String, LoadedPlugin>,
|
||||
}
|
||||
|
||||
struct LoadedPlugin {
|
||||
manifest: PluginManifest,
|
||||
wasm_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Create a new plugin host with the given plugins directory.
|
||||
pub fn new(workspace_dir: &Path) -> Result<Self, PluginError> {
|
||||
let plugins_dir = workspace_dir.join("plugins");
|
||||
if !plugins_dir.exists() {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
}
|
||||
|
||||
let mut host = Self {
|
||||
plugins_dir,
|
||||
loaded: HashMap::new(),
|
||||
};
|
||||
|
||||
host.discover()?;
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Discover plugins in the plugins directory.
|
||||
fn discover(&mut self) -> Result<(), PluginError> {
|
||||
if !self.plugins_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(&self.plugins_dir)?;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let manifest_path = path.join("manifest.toml");
|
||||
if manifest_path.exists() {
|
||||
if let Ok(manifest) = self.load_manifest(&manifest_path) {
|
||||
let wasm_path = path.join(&manifest.wasm_path);
|
||||
self.loaded.insert(
|
||||
manifest.name.clone(),
|
||||
LoadedPlugin {
|
||||
manifest,
|
||||
wasm_path,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_manifest(&self, path: &Path) -> Result<PluginManifest, PluginError> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let manifest: PluginManifest = toml::from_str(&content)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// List all discovered plugins.
|
||||
pub fn list_plugins(&self) -> Vec<PluginInfo> {
|
||||
self.loaded
|
||||
.values()
|
||||
.map(|p| PluginInfo {
|
||||
name: p.manifest.name.clone(),
|
||||
version: p.manifest.version.clone(),
|
||||
description: p.manifest.description.clone(),
|
||||
capabilities: p.manifest.capabilities.clone(),
|
||||
permissions: p.manifest.permissions.clone(),
|
||||
wasm_path: p.wasm_path.clone(),
|
||||
loaded: p.wasm_path.exists(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get info about a specific plugin.
|
||||
pub fn get_plugin(&self, name: &str) -> Option<PluginInfo> {
|
||||
self.loaded.get(name).map(|p| PluginInfo {
|
||||
name: p.manifest.name.clone(),
|
||||
version: p.manifest.version.clone(),
|
||||
description: p.manifest.description.clone(),
|
||||
capabilities: p.manifest.capabilities.clone(),
|
||||
permissions: p.manifest.permissions.clone(),
|
||||
wasm_path: p.wasm_path.clone(),
|
||||
loaded: p.wasm_path.exists(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a plugin from a directory path.
|
||||
pub fn install(&mut self, source: &str) -> Result<(), PluginError> {
|
||||
let source_path = PathBuf::from(source);
|
||||
let manifest_path = if source_path.is_dir() {
|
||||
source_path.join("manifest.toml")
|
||||
} else {
|
||||
source_path.clone()
|
||||
};
|
||||
|
||||
if !manifest_path.exists() {
|
||||
return Err(PluginError::NotFound(format!(
|
||||
"manifest.toml not found at {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let manifest = self.load_manifest(&manifest_path)?;
|
||||
let source_dir = manifest_path
|
||||
.parent()
|
||||
.ok_or_else(|| PluginError::InvalidManifest("no parent directory".into()))?;
|
||||
|
||||
let wasm_source = source_dir.join(&manifest.wasm_path);
|
||||
if !wasm_source.exists() {
|
||||
return Err(PluginError::NotFound(format!(
|
||||
"WASM file not found: {}",
|
||||
wasm_source.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if self.loaded.contains_key(&manifest.name) {
|
||||
return Err(PluginError::AlreadyLoaded(manifest.name));
|
||||
}
|
||||
|
||||
// Copy plugin to plugins directory
|
||||
let dest_dir = self.plugins_dir.join(&manifest.name);
|
||||
std::fs::create_dir_all(&dest_dir)?;
|
||||
|
||||
// Copy manifest
|
||||
std::fs::copy(&manifest_path, dest_dir.join("manifest.toml"))?;
|
||||
|
||||
// Copy WASM file
|
||||
let wasm_dest = dest_dir.join(&manifest.wasm_path);
|
||||
if let Some(parent) = wasm_dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&wasm_source, &wasm_dest)?;
|
||||
|
||||
self.loaded.insert(
|
||||
manifest.name.clone(),
|
||||
LoadedPlugin {
|
||||
manifest,
|
||||
wasm_path: wasm_dest,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a plugin by name.
|
||||
pub fn remove(&mut self, name: &str) -> Result<(), PluginError> {
|
||||
if self.loaded.remove(name).is_none() {
|
||||
return Err(PluginError::NotFound(name.to_string()));
|
||||
}
|
||||
|
||||
let plugin_dir = self.plugins_dir.join(name);
|
||||
if plugin_dir.exists() {
|
||||
std::fs::remove_dir_all(plugin_dir)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get tool-capable plugins.
|
||||
pub fn tool_plugins(&self) -> Vec<&PluginManifest> {
|
||||
self.loaded
|
||||
.values()
|
||||
.filter(|p| p.manifest.capabilities.contains(&PluginCapability::Tool))
|
||||
.map(|p| &p.manifest)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get channel-capable plugins.
|
||||
pub fn channel_plugins(&self) -> Vec<&PluginManifest> {
|
||||
self.loaded
|
||||
.values()
|
||||
.filter(|p| p.manifest.capabilities.contains(&PluginCapability::Channel))
|
||||
.map(|p| &p.manifest)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the plugins directory path.
|
||||
pub fn plugins_dir(&self) -> &Path {
|
||||
&self.plugins_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn test_empty_plugin_dir() {
|
||||
let dir = tempdir().unwrap();
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
assert!(host.list_plugins().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_with_manifest() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugin_dir = dir.path().join("plugins").join("test-plugin");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "test-plugin"
|
||||
version = "0.1.0"
|
||||
description = "A test plugin"
|
||||
wasm_path = "plugin.wasm"
|
||||
capabilities = ["tool"]
|
||||
permissions = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
let plugins = host.list_plugins();
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(plugins[0].name, "test-plugin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_plugins_filter() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugins_base = dir.path().join("plugins");
|
||||
|
||||
// Tool plugin
|
||||
let tool_dir = plugins_base.join("my-tool");
|
||||
std::fs::create_dir_all(&tool_dir).unwrap();
|
||||
std::fs::write(
|
||||
tool_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "my-tool"
|
||||
version = "0.1.0"
|
||||
wasm_path = "tool.wasm"
|
||||
capabilities = ["tool"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Channel plugin
|
||||
let chan_dir = plugins_base.join("my-channel");
|
||||
std::fs::create_dir_all(&chan_dir).unwrap();
|
||||
std::fs::write(
|
||||
chan_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "my-channel"
|
||||
version = "0.1.0"
|
||||
wasm_path = "channel.wasm"
|
||||
capabilities = ["channel"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
assert_eq!(host.list_plugins().len(), 2);
|
||||
assert_eq!(host.tool_plugins().len(), 1);
|
||||
assert_eq!(host.channel_plugins().len(), 1);
|
||||
assert_eq!(host.tool_plugins()[0].name, "my-tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_plugin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugin_dir = dir.path().join("plugins").join("lookup-test");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "lookup-test"
|
||||
version = "1.0.0"
|
||||
description = "Lookup test"
|
||||
wasm_path = "plugin.wasm"
|
||||
capabilities = ["tool"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let host = PluginHost::new(dir.path()).unwrap();
|
||||
assert!(host.get_plugin("lookup-test").is_some());
|
||||
assert!(host.get_plugin("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_plugin() {
|
||||
let dir = tempdir().unwrap();
|
||||
let plugin_dir = dir.path().join("plugins").join("removable");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
std::fs::write(
|
||||
plugin_dir.join("manifest.toml"),
|
||||
r#"
|
||||
name = "removable"
|
||||
version = "0.1.0"
|
||||
wasm_path = "plugin.wasm"
|
||||
capabilities = ["tool"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut host = PluginHost::new(dir.path()).unwrap();
|
||||
assert_eq!(host.list_plugins().len(), 1);
|
||||
|
||||
host.remove("removable").unwrap();
|
||||
assert!(host.list_plugins().is_empty());
|
||||
assert!(!plugin_dir.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_nonexistent_returns_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let mut host = PluginHost::new(dir.path()).unwrap();
|
||||
assert!(host.remove("ghost").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! WASM plugin system for ZeroClaw.
|
||||
//!
|
||||
//! Plugins are WebAssembly modules loaded via Extism that can extend
|
||||
//! ZeroClaw with custom tools and channels. Enable with `--features plugins-wasm`.
|
||||
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod wasm_channel;
|
||||
pub mod wasm_tool;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A plugin's declared manifest (loaded from manifest.toml alongside the .wasm).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginManifest {
|
||||
/// Plugin name (unique identifier)
|
||||
pub name: String,
|
||||
/// Plugin version
|
||||
pub version: String,
|
||||
/// Human-readable description
|
||||
pub description: Option<String>,
|
||||
/// Author name or organization
|
||||
pub author: Option<String>,
|
||||
/// Path to the .wasm file (relative to manifest)
|
||||
pub wasm_path: String,
|
||||
/// Capabilities this plugin provides
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// Permissions this plugin requests
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
}
|
||||
|
||||
/// What a plugin can do.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginCapability {
|
||||
/// Provides one or more tools
|
||||
Tool,
|
||||
/// Provides a channel implementation
|
||||
Channel,
|
||||
/// Provides a memory backend
|
||||
Memory,
|
||||
/// Provides an observer/metrics backend
|
||||
Observer,
|
||||
}
|
||||
|
||||
/// Permissions a plugin may request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginPermission {
|
||||
/// Can make HTTP requests
|
||||
HttpClient,
|
||||
/// Can read from the filesystem (within sandbox)
|
||||
FileRead,
|
||||
/// Can write to the filesystem (within sandbox)
|
||||
FileWrite,
|
||||
/// Can access environment variables
|
||||
EnvRead,
|
||||
/// Can read agent memory
|
||||
MemoryRead,
|
||||
/// Can write agent memory
|
||||
MemoryWrite,
|
||||
}
|
||||
|
||||
/// Information about a loaded plugin.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PluginInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
pub permissions: Vec<PluginPermission>,
|
||||
pub wasm_path: PathBuf,
|
||||
pub loaded: bool,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Bridge between WASM plugins and the Channel trait.
|
||||
|
||||
use crate::channels::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// A channel backed by a WASM plugin.
|
||||
pub struct WasmChannel {
|
||||
name: String,
|
||||
plugin_name: String,
|
||||
}
|
||||
|
||||
impl WasmChannel {
|
||||
pub fn new(name: String, plugin_name: String) -> Self {
|
||||
Self { name, plugin_name }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for WasmChannel {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
// TODO: Wire to WASM plugin send function
|
||||
tracing::warn!(
|
||||
"WasmChannel '{}' (plugin: {}) send not yet connected: {}",
|
||||
self.name,
|
||||
self.plugin_name,
|
||||
message.content
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
// TODO: Wire to WASM plugin receive/listen function
|
||||
tracing::warn!(
|
||||
"WasmChannel '{}' (plugin: {}) listen not yet connected",
|
||||
self.name,
|
||||
self.plugin_name,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Bridge between WASM plugins and the Tool trait.
|
||||
|
||||
use crate::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A tool backed by a WASM plugin function.
|
||||
pub struct WasmTool {
|
||||
name: String,
|
||||
description: String,
|
||||
plugin_name: String,
|
||||
function_name: String,
|
||||
parameters_schema: Value,
|
||||
}
|
||||
|
||||
impl WasmTool {
|
||||
pub fn new(
|
||||
name: String,
|
||||
description: String,
|
||||
plugin_name: String,
|
||||
function_name: String,
|
||||
parameters_schema: Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
description,
|
||||
plugin_name,
|
||||
function_name,
|
||||
parameters_schema,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WasmTool {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
&self.description
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
self.parameters_schema.clone()
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
|
||||
// TODO: Call into Extism plugin runtime
|
||||
// For now, return a placeholder indicating the plugin system is available
|
||||
// but not yet wired to actual WASM execution.
|
||||
Ok(ToolResult {
|
||||
success: false,
|
||||
output: format!(
|
||||
"[plugin:{}/{}] WASM execution not yet connected. Args: {}",
|
||||
self.plugin_name,
|
||||
self.function_name,
|
||||
serde_json::to_string(&args).unwrap_or_default()
|
||||
),
|
||||
error: Some("WASM execution bridge not yet implemented".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
+50
-41
@@ -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]
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
//!
|
||||
//! # Limitations
|
||||
//!
|
||||
//! - **Conversation history**: Only the system prompt (if present) and the last
|
||||
//! user message are forwarded. Full multi-turn history is not preserved because
|
||||
//! the CLI accepts a single prompt per invocation.
|
||||
//! - **System prompt**: The system prompt is prepended to the user message with a
|
||||
//! blank-line separator, as the CLI does not provide a dedicated system-prompt flag.
|
||||
//! - **Temperature**: The CLI does not expose a temperature parameter.
|
||||
@@ -34,7 +31,7 @@
|
||||
//!
|
||||
//! - `CLAUDE_CODE_PATH` — override the path to the `claude` binary (default: `"claude"`)
|
||||
|
||||
use crate::providers::traits::{ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use crate::providers::traits::{ChatMessage, ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -212,6 +209,54 @@ impl Provider for ClaudeCodeProvider {
|
||||
self.invoke_cli(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Self::validate_temperature(temperature)?;
|
||||
|
||||
// Separate system prompt from conversation messages.
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.as_str());
|
||||
|
||||
// Build conversation turns (skip system messages).
|
||||
let turns: Vec<&ChatMessage> = messages.iter().filter(|m| m.role != "system").collect();
|
||||
|
||||
// If there's only one user message, use the simple path.
|
||||
if turns.len() <= 1 {
|
||||
let last_user = turns.first().map(|m| m.content.as_str()).unwrap_or("");
|
||||
let full_message = match system {
|
||||
Some(s) if !s.is_empty() => format!("{s}\n\n{last_user}"),
|
||||
_ => last_user.to_string(),
|
||||
};
|
||||
return self.invoke_cli(&full_message, model).await;
|
||||
}
|
||||
|
||||
// Format multi-turn conversation into a single prompt.
|
||||
let mut parts = Vec::new();
|
||||
if let Some(s) = system {
|
||||
if !s.is_empty() {
|
||||
parts.push(format!("[system]\n{s}"));
|
||||
}
|
||||
}
|
||||
for msg in &turns {
|
||||
let label = match msg.role.as_str() {
|
||||
"user" => "[user]",
|
||||
"assistant" => "[assistant]",
|
||||
other => other,
|
||||
};
|
||||
parts.push(format!("{label}\n{}", msg.content));
|
||||
}
|
||||
parts.push("[assistant]".to_string());
|
||||
|
||||
let full_message = parts.join("\n\n");
|
||||
self.invoke_cli(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
@@ -234,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, ()> {
|
||||
@@ -327,4 +373,108 @@ mod tests {
|
||||
"unexpected error message: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: create a provider that uses a shell script echoing stdin back.
|
||||
/// The script ignores CLI flags (`--print`, `--model`, `-`) and just cats stdin.
|
||||
fn echo_provider() -> ClaudeCodeProvider {
|
||||
use std::io::Write;
|
||||
|
||||
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]
|
||||
async fn chat_with_history_single_user_message() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![ChatMessage::user("hello")];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_single_user_with_system() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::system("You are helpful."),
|
||||
ChatMessage::user("hello"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "You are helpful.\n\nhello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_multi_turn_includes_all_messages() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::system("Be concise."),
|
||||
ChatMessage::user("What is 2+2?"),
|
||||
ChatMessage::assistant("4"),
|
||||
ChatMessage::user("And 3+3?"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.contains("[system]\nBe concise."));
|
||||
assert!(result.contains("[user]\nWhat is 2+2?"));
|
||||
assert!(result.contains("[assistant]\n4"));
|
||||
assert!(result.contains("[user]\nAnd 3+3?"));
|
||||
assert!(result.ends_with("[assistant]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_multi_turn_without_system() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![
|
||||
ChatMessage::user("hi"),
|
||||
ChatMessage::assistant("hello"),
|
||||
ChatMessage::user("bye"),
|
||||
];
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "default", 1.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!result.contains("[system]"));
|
||||
assert!(result.contains("[user]\nhi"));
|
||||
assert!(result.contains("[assistant]\nhello"));
|
||||
assert!(result.contains("[user]\nbye"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_rejects_bad_temperature() {
|
||||
let provider = echo_provider();
|
||||
let messages = vec![ChatMessage::user("test")];
|
||||
let result = provider.chat_with_history(&messages, "default", 0.5).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 @@ fn create_provider_with_url_and_options(
|
||||
)?))
|
||||
}
|
||||
// ── Primary providers (custom implementations) ───────
|
||||
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(key))),
|
||||
"openrouter" => Ok(Box::new(openrouter::OpenRouterProvider::new(
|
||||
key,
|
||||
options.provider_timeout_secs,
|
||||
))),
|
||||
"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)
|
||||
@@ -1320,11 +1323,12 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("llama.cpp");
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new_with_vision(
|
||||
"llama.cpp",
|
||||
base_url,
|
||||
Some(llama_cpp_key),
|
||||
AuthStyle::Bearer,
|
||||
true,
|
||||
)))
|
||||
}
|
||||
"sglang" => {
|
||||
|
||||
+133
-13
@@ -6,12 +6,17 @@ use crate::providers::traits::{
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub struct OpenRouterProvider {
|
||||
credential: Option<String>,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
const DEFAULT_OPENROUTER_TIMEOUT_SECS: u64 = 120;
|
||||
const OPENROUTER_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
@@ -146,12 +151,21 @@ struct NativeResponseMessage {
|
||||
}
|
||||
|
||||
impl OpenRouterProvider {
|
||||
pub fn new(credential: Option<&str>) -> Self {
|
||||
pub fn new(credential: Option<&str>, timeout_secs: Option<u64>) -> Self {
|
||||
Self {
|
||||
credential: credential.map(ToString::to_string),
|
||||
timeout_secs: timeout_secs
|
||||
.filter(|secs| *secs > 0)
|
||||
.unwrap_or(DEFAULT_OPENROUTER_TIMEOUT_SECS),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -295,8 +309,44 @@ impl OpenRouterProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_sanitized_body_snippet(body: &str) -> String {
|
||||
super::sanitize_api_error(body)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
async fn read_response_body(
|
||||
provider_name: &str,
|
||||
response: reqwest::Response,
|
||||
) -> anyhow::Result<String> {
|
||||
response.text().await.map_err(|error| {
|
||||
let sanitized = super::sanitize_api_error(&error.to_string());
|
||||
anyhow::anyhow!(
|
||||
"{provider_name} transport error while reading response body: {sanitized}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_response_body<T: DeserializeOwned>(
|
||||
provider_name: &str,
|
||||
body: &str,
|
||||
kind: &str,
|
||||
) -> anyhow::Result<T> {
|
||||
serde_json::from_str::<T>(body).map_err(|error| {
|
||||
let snippet = Self::compact_sanitized_body_snippet(body);
|
||||
anyhow::anyhow!(
|
||||
"{provider_name} API returned an unexpected {kind} payload: {error}; body={snippet}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
OPENROUTER_CONNECT_TIMEOUT_SECS,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +418,9 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let body = Self::read_response_body("OpenRouter", response).await?;
|
||||
let chat_response =
|
||||
Self::parse_response_body::<ApiChatResponse>("OpenRouter", &body, "chat-completions")?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
@@ -415,7 +467,9 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let body = Self::read_response_body("OpenRouter", response).await?;
|
||||
let chat_response =
|
||||
Self::parse_response_body::<ApiChatResponse>("OpenRouter", &body, "chat-completions")?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
@@ -460,7 +514,9 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let body = Self::read_response_body("OpenRouter", response).await?;
|
||||
let native_response =
|
||||
Self::parse_response_body::<NativeChatResponse>("OpenRouter", &body, "native chat")?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
@@ -552,7 +608,9 @@ impl Provider for OpenRouterProvider {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let body = Self::read_response_body("OpenRouter", response).await?;
|
||||
let native_response =
|
||||
Self::parse_response_body::<NativeChatResponse>("OpenRouter", &body, "native chat")?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
@@ -577,7 +635,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn capabilities_report_vision_support() {
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"));
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), None);
|
||||
let caps = <OpenRouterProvider as Provider>::capabilities(&provider);
|
||||
assert!(caps.native_tool_calling);
|
||||
assert!(caps.vision);
|
||||
@@ -585,7 +643,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn creates_with_key() {
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"));
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), None);
|
||||
assert_eq!(
|
||||
provider.credential.as_deref(),
|
||||
Some("openrouter-test-credential")
|
||||
@@ -594,20 +652,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn creates_without_key() {
|
||||
let provider = OpenRouterProvider::new(None);
|
||||
let provider = OpenRouterProvider::new(None, None);
|
||||
assert!(provider.credential.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_configured_timeout_when_provided() {
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), Some(1200));
|
||||
assert_eq!(provider.timeout_secs, 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_default_timeout_for_zero() {
|
||||
let provider = OpenRouterProvider::new(Some("openrouter-test-credential"), Some(0));
|
||||
assert_eq!(provider.timeout_secs, DEFAULT_OPENROUTER_TIMEOUT_SECS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warmup_without_key_is_noop() {
|
||||
let provider = OpenRouterProvider::new(None);
|
||||
let provider = OpenRouterProvider::new(None, None);
|
||||
let result = provider.warmup().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_system_fails_without_key() {
|
||||
let provider = OpenRouterProvider::new(None);
|
||||
let provider = OpenRouterProvider::new(None, None);
|
||||
let result = provider
|
||||
.chat_with_system(Some("system"), "hello", "openai/gpt-4o", 0.2)
|
||||
.await;
|
||||
@@ -618,7 +688,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_fails_without_key() {
|
||||
let provider = OpenRouterProvider::new(None);
|
||||
let provider = OpenRouterProvider::new(None, None);
|
||||
let messages = vec![
|
||||
ChatMessage {
|
||||
role: "system".into(),
|
||||
@@ -713,9 +783,43 @@ mod tests {
|
||||
assert!(response.choices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chat_response_body_reports_sanitized_snippet() {
|
||||
let body = r#"{"choices":"invalid","api_key":"sk-test-secret-value"}"#;
|
||||
let err = OpenRouterProvider::parse_response_body::<ApiChatResponse>(
|
||||
"OpenRouter",
|
||||
body,
|
||||
"chat-completions",
|
||||
)
|
||||
.expect_err("payload should fail");
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("OpenRouter API returned an unexpected chat-completions payload"));
|
||||
assert!(msg.contains("body="));
|
||||
assert!(msg.contains("[REDACTED]"));
|
||||
assert!(!msg.contains("sk-test-secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_native_response_body_reports_sanitized_snippet() {
|
||||
let body = r#"{"choices":123,"api_key":"sk-another-secret"}"#;
|
||||
let err = OpenRouterProvider::parse_response_body::<NativeChatResponse>(
|
||||
"OpenRouter",
|
||||
body,
|
||||
"native chat",
|
||||
)
|
||||
.expect_err("payload should fail");
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("OpenRouter API returned an unexpected native chat payload"));
|
||||
assert!(msg.contains("body="));
|
||||
assert!(msg.contains("[REDACTED]"));
|
||||
assert!(!msg.contains("sk-another-secret"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_tools_fails_without_key() {
|
||||
let provider = OpenRouterProvider::new(None);
|
||||
let provider = OpenRouterProvider::new(None, None);
|
||||
let messages = vec![ChatMessage {
|
||||
role: "user".into(),
|
||||
content: "What is the date?".into(),
|
||||
@@ -1017,4 +1121,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"), None);
|
||||
assert_eq!(provider.timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_timeout_secs_overrides_default() {
|
||||
let provider = OpenRouterProvider::new(Some("key"), None).with_timeout_secs(300);
|
||||
assert_eq!(provider.timeout_secs, 300);
|
||||
}
|
||||
}
|
||||
|
||||
+324
-41
@@ -16,8 +16,17 @@ use std::time::Duration;
|
||||
|
||||
/// Check if an error is non-retryable (client errors that won't resolve with retries).
|
||||
pub fn is_non_retryable(err: &anyhow::Error) -> bool {
|
||||
// Context window errors are NOT non-retryable — they can be recovered
|
||||
// by truncating conversation history, so let the retry loop handle them.
|
||||
if is_context_window_exceeded(err) {
|
||||
return true;
|
||||
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.),
|
||||
@@ -71,10 +80,27 @@ 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 = [
|
||||
"exceeds the context window",
|
||||
"exceeds the available context size",
|
||||
"context window of this model",
|
||||
"maximum context length",
|
||||
"context length exceeded",
|
||||
@@ -197,6 +223,35 @@ fn compact_error_detail(err: &anyhow::Error) -> String {
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Truncate conversation history by dropping the oldest non-system messages.
|
||||
/// Returns the number of messages dropped. Keeps at least the system message
|
||||
/// (if any) and the most recent user message.
|
||||
fn truncate_for_context(messages: &mut Vec<ChatMessage>) -> usize {
|
||||
// Find all non-system message indices
|
||||
let non_system: Vec<usize> = messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| m.role != "system")
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Keep at least the last non-system message (most recent user turn)
|
||||
if non_system.len() <= 1 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Drop the oldest half of non-system messages
|
||||
let drop_count = non_system.len() / 2;
|
||||
let indices_to_remove: Vec<usize> = non_system[..drop_count].to_vec();
|
||||
|
||||
// Remove in reverse order to preserve indices
|
||||
for &idx in indices_to_remove.iter().rev() {
|
||||
messages.remove(idx);
|
||||
}
|
||||
|
||||
drop_count
|
||||
}
|
||||
|
||||
fn push_failure(
|
||||
failures: &mut Vec<String>,
|
||||
provider_name: &str,
|
||||
@@ -338,6 +393,25 @@ impl Provider for ReliableProvider {
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: no history to truncate
|
||||
// in chat_with_system, bail immediately.
|
||||
if is_context_window_exceeded(&e) {
|
||||
let error_detail = compact_error_detail(&e);
|
||||
push_failure(
|
||||
&mut failures,
|
||||
provider_name,
|
||||
current_model,
|
||||
attempt + 1,
|
||||
self.max_retries + 1,
|
||||
"non_retryable",
|
||||
&error_detail,
|
||||
);
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -376,14 +450,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -435,6 +501,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -442,22 +510,39 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
match provider
|
||||
.chat_with_history(messages, current_model, temperature)
|
||||
.chat_with_history(&effective_messages, current_model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -494,14 +579,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -559,6 +636,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -566,22 +645,39 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
match provider
|
||||
.chat_with_tools(messages, tools, current_model, temperature)
|
||||
.chat_with_tools(&effective_messages, tools, current_model, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -618,14 +714,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -669,6 +757,8 @@ impl Provider for ReliableProvider {
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
let mut effective_messages = request.messages.to_vec();
|
||||
let mut context_truncated = false;
|
||||
|
||||
for current_model in &models {
|
||||
for (provider_name, provider) in &self.providers {
|
||||
@@ -676,23 +766,40 @@ impl Provider for ReliableProvider {
|
||||
|
||||
for attempt in 0..=self.max_retries {
|
||||
let req = ChatRequest {
|
||||
messages: request.messages,
|
||||
messages: &effective_messages,
|
||||
tools: request.tools,
|
||||
};
|
||||
match provider.chat(req, current_model, temperature).await {
|
||||
Ok(resp) => {
|
||||
if attempt > 0 || *current_model != model {
|
||||
if attempt > 0 || *current_model != model || context_truncated {
|
||||
tracing::info!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
attempt,
|
||||
original_model = model,
|
||||
context_truncated,
|
||||
"Provider recovered (failover/retry)"
|
||||
);
|
||||
}
|
||||
return Ok(resp);
|
||||
}
|
||||
Err(e) => {
|
||||
// Context window exceeded: truncate history and retry
|
||||
if is_context_window_exceeded(&e) && !context_truncated {
|
||||
let dropped = truncate_for_context(&mut effective_messages);
|
||||
if dropped > 0 {
|
||||
context_truncated = true;
|
||||
tracing::warn!(
|
||||
provider = provider_name,
|
||||
model = *current_model,
|
||||
dropped,
|
||||
remaining = effective_messages.len(),
|
||||
"Context window exceeded; truncated history and retrying"
|
||||
);
|
||||
continue; // Retry with truncated messages (counts as an attempt)
|
||||
}
|
||||
}
|
||||
|
||||
let non_retryable_rate_limit = is_non_retryable_rate_limit(&e);
|
||||
let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit;
|
||||
let rate_limited = is_rate_limited(&e);
|
||||
@@ -729,14 +836,6 @@ impl Provider for ReliableProvider {
|
||||
error = %error_detail,
|
||||
"Non-retryable error, moving on"
|
||||
);
|
||||
|
||||
if is_context_window_exceeded(&e) {
|
||||
anyhow::bail!(
|
||||
"Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1071,7 +1170,8 @@ mod tests {
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"model overloaded, try again later"
|
||||
)));
|
||||
assert!(is_non_retryable(&anyhow::anyhow!(
|
||||
// Context window errors are now recoverable (not non-retryable)
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"OpenAI Codex stream error: Your input exceeds the context window of this model."
|
||||
)));
|
||||
}
|
||||
@@ -1107,7 +1207,7 @@ mod tests {
|
||||
let msg = err.to_string();
|
||||
|
||||
assert!(msg.contains("context window"));
|
||||
assert!(msg.contains("skipped"));
|
||||
// chat_with_system has no history to truncate, so it bails immediately
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
@@ -1980,4 +2080,187 @@ mod tests {
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ── Context window truncation tests ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn context_window_error_is_not_non_retryable() {
|
||||
// Context window errors should be recoverable via truncation
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"exceeds the context window"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"maximum context length exceeded"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!(
|
||||
"too many tokens in the request"
|
||||
)));
|
||||
assert!(!is_non_retryable(&anyhow::anyhow!("token limit exceeded")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_context_window_exceeded_detects_llamacpp() {
|
||||
assert!(is_context_window_exceeded(&anyhow::anyhow!(
|
||||
"request (8968 tokens) exceeds the available context size (8448 tokens), try increasing it"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_context_drops_oldest_non_system() {
|
||||
let mut messages = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user("msg1"),
|
||||
ChatMessage::assistant("resp1"),
|
||||
ChatMessage::user("msg2"),
|
||||
ChatMessage::assistant("resp2"),
|
||||
ChatMessage::user("msg3"),
|
||||
];
|
||||
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
|
||||
// 5 non-system messages, drop oldest half = 2
|
||||
assert_eq!(dropped, 2);
|
||||
// System message preserved
|
||||
assert_eq!(messages[0].role, "system");
|
||||
// Remaining messages should be the newer ones
|
||||
assert_eq!(messages.len(), 4); // system + 3 remaining non-system
|
||||
// The last message should still be the most recent user message
|
||||
assert_eq!(messages.last().unwrap().content, "msg3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_for_context_preserves_system_and_last_message() {
|
||||
// Only one non-system message: nothing to drop
|
||||
let mut messages = vec![ChatMessage::system("sys"), ChatMessage::user("only")];
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(messages.len(), 2);
|
||||
|
||||
// No system message, only one user message
|
||||
let mut messages = vec![ChatMessage::user("only")];
|
||||
let dropped = truncate_for_context(&mut messages);
|
||||
assert_eq!(dropped, 0);
|
||||
assert_eq!(messages.len(), 1);
|
||||
}
|
||||
|
||||
/// Mock that fails with context error on first N calls, then succeeds.
|
||||
/// Tracks the number of messages received on each call.
|
||||
struct ContextOverflowMock {
|
||||
calls: Arc<AtomicUsize>,
|
||||
fail_until_attempt: usize,
|
||||
message_counts: parking_lot::Mutex<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for ContextOverflowMock {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.message_counts.lock().push(messages.len());
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(
|
||||
"request (8968 tokens) exceeds the available context size (8448 tokens), try increasing it"
|
||||
);
|
||||
}
|
||||
Ok("recovered after truncation".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_history_truncates_on_context_overflow() {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mock = ContextOverflowMock {
|
||||
calls: Arc::clone(&calls),
|
||||
fail_until_attempt: 1, // fail first call, succeed after truncation
|
||||
message_counts: parking_lot::Mutex::new(Vec::new()),
|
||||
};
|
||||
|
||||
let provider = ReliableProvider::new(
|
||||
vec![("local".into(), Box::new(mock) as Box<dyn Provider>)],
|
||||
3,
|
||||
1,
|
||||
);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::system("system prompt"),
|
||||
ChatMessage::user("old message 1"),
|
||||
ChatMessage::assistant("old response 1"),
|
||||
ChatMessage::user("old message 2"),
|
||||
ChatMessage::assistant("old response 2"),
|
||||
ChatMessage::user("current question"),
|
||||
];
|
||||
|
||||
let result = provider
|
||||
.chat_with_history(&messages, "local-model", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "recovered after truncation");
|
||||
// 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)
|
||||
}
|
||||
@@ -1296,6 +1364,93 @@ impl SecurityPolicy {
|
||||
tracker: ActionTracker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a human-readable summary of the active security constraints
|
||||
/// suitable for injection into the LLM system prompt.
|
||||
///
|
||||
/// Giving the LLM visibility into these constraints prevents it from
|
||||
/// wasting tokens on commands / paths that will be rejected at runtime.
|
||||
/// See issue #2404.
|
||||
pub fn prompt_summary(&self) -> String {
|
||||
use std::fmt::Write;
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
// Autonomy level
|
||||
let _ = writeln!(out, "**Autonomy level**: {:?}", self.autonomy);
|
||||
|
||||
// Workspace constraint
|
||||
if self.workspace_only {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"**Workspace boundary**: file operations are restricted to `{}`.",
|
||||
self.workspace_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Allowed roots
|
||||
if !self.allowed_roots.is_empty() {
|
||||
let roots: Vec<String> = self
|
||||
.allowed_roots
|
||||
.iter()
|
||||
.map(|p| format!("`{}`", p.display()))
|
||||
.collect();
|
||||
let _ = writeln!(out, "**Additional allowed paths**: {}", roots.join(", "));
|
||||
}
|
||||
|
||||
// Allowed commands
|
||||
if !self.allowed_commands.is_empty() {
|
||||
let cmds: Vec<String> = self
|
||||
.allowed_commands
|
||||
.iter()
|
||||
.map(|c| format!("`{c}`"))
|
||||
.collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"**Allowed shell commands**: {}. \
|
||||
Commands not on this list will be rejected.",
|
||||
cmds.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Forbidden paths
|
||||
if !self.forbidden_paths.is_empty() {
|
||||
let paths: Vec<String> = self
|
||||
.forbidden_paths
|
||||
.iter()
|
||||
.map(|p| format!("`{p}`"))
|
||||
.collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"**Forbidden paths**: {}. \
|
||||
Any read/write/exec targeting these paths will be blocked.",
|
||||
paths.join(", ")
|
||||
);
|
||||
}
|
||||
|
||||
// Risk controls
|
||||
if self.block_high_risk_commands {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"**High-risk commands** (rm, kill, reboot, etc.) are blocked."
|
||||
);
|
||||
}
|
||||
if self.require_approval_for_medium_risk {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"**Medium-risk commands** require user approval before execution."
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"**Rate limit**: max {} actions per hour.",
|
||||
self.max_actions_per_hour
|
||||
);
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2720,6 +2875,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 +2912,152 @@ 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")));
|
||||
}
|
||||
|
||||
// ── prompt_summary ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_autonomy_level() {
|
||||
let p = default_policy();
|
||||
let summary = p.prompt_summary();
|
||||
assert!(
|
||||
summary.contains("Supervised"),
|
||||
"should mention autonomy level"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_workspace_boundary_when_workspace_only() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_dir: PathBuf::from("/home/user/project"),
|
||||
workspace_only: true,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(
|
||||
summary.contains("Workspace boundary"),
|
||||
"should mention workspace boundary"
|
||||
);
|
||||
assert!(
|
||||
summary.contains("/home/user/project"),
|
||||
"should mention workspace path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_omits_workspace_boundary_when_not_workspace_only() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_only: false,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(
|
||||
!summary.contains("Workspace boundary"),
|
||||
"should not mention workspace boundary"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_allowed_commands() {
|
||||
let p = SecurityPolicy {
|
||||
allowed_commands: vec!["git".into(), "ls".into()],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(summary.contains("`git`"), "should list allowed commands");
|
||||
assert!(summary.contains("`ls`"), "should list allowed commands");
|
||||
assert!(
|
||||
summary.contains("not on this list will be rejected"),
|
||||
"should warn about rejection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_forbidden_paths() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_only: false,
|
||||
forbidden_paths: vec!["/etc".into(), "~/.ssh".into()],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(summary.contains("`/etc`"), "should list forbidden paths");
|
||||
assert!(summary.contains("`~/.ssh`"), "should list forbidden paths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_rate_limit() {
|
||||
let p = SecurityPolicy {
|
||||
max_actions_per_hour: 42,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(summary.contains("42"), "should mention rate limit");
|
||||
assert!(
|
||||
summary.contains("actions per hour"),
|
||||
"should explain rate limit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_risk_controls() {
|
||||
let p = SecurityPolicy {
|
||||
block_high_risk_commands: true,
|
||||
require_approval_for_medium_risk: true,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(
|
||||
summary.contains("High-risk commands"),
|
||||
"should mention high-risk block"
|
||||
);
|
||||
assert!(
|
||||
summary.contains("Medium-risk commands"),
|
||||
"should mention medium-risk approval"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_summary_includes_allowed_roots() {
|
||||
let p = SecurityPolicy {
|
||||
allowed_roots: vec![PathBuf::from("/shared/data"), PathBuf::from("/opt/tools")],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
let summary = p.prompt_summary();
|
||||
assert!(
|
||||
summary.contains("`/shared/data`"),
|
||||
"should list allowed roots"
|
||||
);
|
||||
assert!(
|
||||
summary.contains("`/opt/tools`"),
|
||||
"should list allowed roots"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+106
-10
@@ -6,6 +6,11 @@ use std::sync::OnceLock;
|
||||
|
||||
const MAX_TEXT_FILE_BYTES: u64 = 512 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SkillAuditOptions {
|
||||
pub allow_scripts: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SkillAuditReport {
|
||||
pub files_scanned: usize,
|
||||
@@ -23,6 +28,13 @@ impl SkillAuditReport {
|
||||
}
|
||||
|
||||
pub fn audit_skill_directory(skill_dir: &Path) -> Result<SkillAuditReport> {
|
||||
audit_skill_directory_with_options(skill_dir, SkillAuditOptions::default())
|
||||
}
|
||||
|
||||
pub fn audit_skill_directory_with_options(
|
||||
skill_dir: &Path,
|
||||
options: SkillAuditOptions,
|
||||
) -> Result<SkillAuditReport> {
|
||||
if !skill_dir.exists() {
|
||||
bail!("Skill source does not exist: {}", skill_dir.display());
|
||||
}
|
||||
@@ -46,7 +58,7 @@ pub fn audit_skill_directory(skill_dir: &Path) -> Result<SkillAuditReport> {
|
||||
|
||||
for path in collect_paths_depth_first(&canonical_root)? {
|
||||
report.files_scanned += 1;
|
||||
audit_path(&canonical_root, &path, &mut report)?;
|
||||
audit_path(&canonical_root, &path, &mut report, options)?;
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
@@ -105,7 +117,12 @@ fn collect_paths_depth_first(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn audit_path(root: &Path, path: &Path, report: &mut SkillAuditReport) -> Result<()> {
|
||||
fn audit_path(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
report: &mut SkillAuditReport,
|
||||
options: SkillAuditOptions,
|
||||
) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.with_context(|| format!("failed to read metadata for {}", path.display()))?;
|
||||
let rel = relative_display(root, path);
|
||||
@@ -121,7 +138,7 @@ fn audit_path(root: &Path, path: &Path, report: &mut SkillAuditReport) -> Result
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if is_unsupported_script_file(path) {
|
||||
if !options.allow_scripts && is_unsupported_script_file(path) {
|
||||
report.findings.push(format!(
|
||||
"{rel}: script-like files are blocked by skill security policy."
|
||||
));
|
||||
@@ -409,13 +426,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 +633,55 @@ 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_allows_shell_script_files_when_enabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skill_dir = dir.path().join("allowed-scripts");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(skill_dir.join("SKILL.md"), "# Skill\n").unwrap();
|
||||
std::fs::write(skill_dir.join("install.sh"), "echo allowed\n").unwrap();
|
||||
|
||||
let report = audit_skill_directory_with_options(
|
||||
&skill_dir,
|
||||
SkillAuditOptions {
|
||||
allow_scripts: true,
|
||||
},
|
||||
)
|
||||
.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();
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
// Autonomous skill creation from successful multi-step task executions.
|
||||
//
|
||||
// After the agent completes a multi-step tool-call sequence, this module
|
||||
// can persist the execution as a reusable skill definition (SKILL.toml)
|
||||
// under `~/.zeroclaw/workspace/skills/<slug>/`.
|
||||
|
||||
use crate::config::SkillCreationConfig;
|
||||
use crate::memory::embeddings::EmbeddingProvider;
|
||||
use crate::memory::vector::cosine_similarity;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A record of a single tool call executed during a task.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolCallRecord {
|
||||
pub name: String,
|
||||
pub args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Creates reusable skill definitions from successful multi-step executions.
|
||||
pub struct SkillCreator {
|
||||
workspace_dir: PathBuf,
|
||||
config: SkillCreationConfig,
|
||||
}
|
||||
|
||||
impl SkillCreator {
|
||||
pub fn new(workspace_dir: PathBuf, config: SkillCreationConfig) -> Self {
|
||||
Self {
|
||||
workspace_dir,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to create a skill from a successful multi-step task execution.
|
||||
/// Returns `Ok(Some(slug))` if a skill was created, `Ok(None)` if skipped
|
||||
/// (disabled, duplicate, or insufficient tool calls).
|
||||
pub async fn create_from_execution(
|
||||
&self,
|
||||
task_description: &str,
|
||||
tool_calls: &[ToolCallRecord],
|
||||
embedding_provider: Option<&dyn EmbeddingProvider>,
|
||||
) -> Result<Option<String>> {
|
||||
if !self.config.enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if tool_calls.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Deduplicate via embeddings when an embedding provider is available.
|
||||
if let Some(provider) = embedding_provider {
|
||||
if provider.name() != "none" && self.is_duplicate(task_description, provider).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
let slug = Self::generate_slug(task_description);
|
||||
if !Self::validate_slug(&slug) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Enforce LRU limit before writing a new skill.
|
||||
self.enforce_lru_limit().await?;
|
||||
|
||||
let skill_dir = self.skills_dir().join(&slug);
|
||||
tokio::fs::create_dir_all(&skill_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to create skill directory: {}", skill_dir.display())
|
||||
})?;
|
||||
|
||||
let toml_content = Self::generate_skill_toml(&slug, task_description, tool_calls);
|
||||
let toml_path = skill_dir.join("SKILL.toml");
|
||||
tokio::fs::write(&toml_path, toml_content.as_bytes())
|
||||
.await
|
||||
.with_context(|| format!("Failed to write {}", toml_path.display()))?;
|
||||
|
||||
Ok(Some(slug))
|
||||
}
|
||||
|
||||
/// Generate a URL-safe slug from a task description.
|
||||
/// Alphanumeric and hyphens only, max 64 characters.
|
||||
fn generate_slug(description: &str) -> String {
|
||||
let slug: String = description
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
|
||||
// Collapse consecutive hyphens.
|
||||
let mut collapsed = String::with_capacity(slug.len());
|
||||
let mut prev_hyphen = false;
|
||||
for c in slug.chars() {
|
||||
if c == '-' {
|
||||
if !prev_hyphen {
|
||||
collapsed.push('-');
|
||||
}
|
||||
prev_hyphen = true;
|
||||
} else {
|
||||
collapsed.push(c);
|
||||
prev_hyphen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim leading/trailing hyphens, then truncate.
|
||||
let trimmed = collapsed.trim_matches('-');
|
||||
if trimmed.len() > 64 {
|
||||
// Truncate at a hyphen boundary if possible.
|
||||
let truncated = &trimmed[..64];
|
||||
truncated.trim_end_matches('-').to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a slug is non-empty, alphanumeric + hyphens, max 64 chars.
|
||||
fn validate_slug(slug: &str) -> bool {
|
||||
!slug.is_empty()
|
||||
&& slug.len() <= 64
|
||||
&& slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||
&& !slug.starts_with('-')
|
||||
&& !slug.ends_with('-')
|
||||
}
|
||||
|
||||
/// Generate SKILL.toml content from task execution data.
|
||||
fn generate_skill_toml(slug: &str, description: &str, tool_calls: &[ToolCallRecord]) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut toml = String::new();
|
||||
toml.push_str("[skill]\n");
|
||||
let _ = writeln!(toml, "name = {}", toml_escape(slug));
|
||||
let _ = writeln!(
|
||||
toml,
|
||||
"description = {}",
|
||||
toml_escape(&format!("Auto-generated: {description}"))
|
||||
);
|
||||
toml.push_str("version = \"0.1.0\"\n");
|
||||
toml.push_str("author = \"zeroclaw-auto\"\n");
|
||||
toml.push_str("tags = [\"auto-generated\"]\n");
|
||||
|
||||
for call in tool_calls {
|
||||
toml.push('\n');
|
||||
toml.push_str("[[tools]]\n");
|
||||
let _ = writeln!(toml, "name = {}", toml_escape(&call.name));
|
||||
let _ = writeln!(
|
||||
toml,
|
||||
"description = {}",
|
||||
toml_escape(&format!("Tool used in task: {}", call.name))
|
||||
);
|
||||
toml.push_str("kind = \"shell\"\n");
|
||||
|
||||
// Extract the command from args if available, otherwise use the tool name.
|
||||
let command = call
|
||||
.args
|
||||
.get("command")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or(&call.name);
|
||||
let _ = writeln!(toml, "command = {}", toml_escape(command));
|
||||
}
|
||||
|
||||
toml
|
||||
}
|
||||
|
||||
/// Check if a skill with a similar description already exists.
|
||||
async fn is_duplicate(
|
||||
&self,
|
||||
description: &str,
|
||||
embedding_provider: &dyn EmbeddingProvider,
|
||||
) -> Result<bool> {
|
||||
let new_embedding = embedding_provider.embed_one(description).await?;
|
||||
if new_embedding.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let skills_dir = self.skills_dir();
|
||||
if !skills_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&skills_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let toml_path = entry.path().join("SKILL.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&toml_path).await?;
|
||||
// Extract description from the TOML to compare.
|
||||
if let Some(desc) = extract_description_from_toml(&content) {
|
||||
let existing_embedding = embedding_provider.embed_one(&desc).await?;
|
||||
if !existing_embedding.is_empty() {
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let similarity =
|
||||
f64::from(cosine_similarity(&new_embedding, &existing_embedding));
|
||||
if similarity > self.config.similarity_threshold {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Remove the oldest auto-generated skill when we exceed `max_skills`.
|
||||
async fn enforce_lru_limit(&self) -> Result<()> {
|
||||
let skills_dir = self.skills_dir();
|
||||
if !skills_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut auto_skills: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
|
||||
|
||||
let mut entries = tokio::fs::read_dir(&skills_dir).await?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let toml_path = entry.path().join("SKILL.toml");
|
||||
if !toml_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = tokio::fs::read_to_string(&toml_path).await?;
|
||||
if content.contains("\"zeroclaw-auto\"") || content.contains("\"auto-generated\"") {
|
||||
let modified = tokio::fs::metadata(&toml_path)
|
||||
.await?
|
||||
.modified()
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
auto_skills.push((entry.path(), modified));
|
||||
}
|
||||
}
|
||||
|
||||
// If at or above the limit, remove the oldest.
|
||||
if auto_skills.len() >= self.config.max_skills {
|
||||
auto_skills.sort_by_key(|(_, modified)| *modified);
|
||||
if let Some((oldest_dir, _)) = auto_skills.first() {
|
||||
tokio::fs::remove_dir_all(oldest_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Failed to remove oldest auto-generated skill: {}",
|
||||
oldest_dir.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn skills_dir(&self) -> PathBuf {
|
||||
self.workspace_dir.join("skills")
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape a string for TOML value (double-quoted).
|
||||
fn toml_escape(s: &str) -> String {
|
||||
let escaped = s
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\n', "\\n")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\t', "\\t");
|
||||
format!("\"{escaped}\"")
|
||||
}
|
||||
|
||||
/// Extract the description field from a SKILL.toml string.
|
||||
fn extract_description_from_toml(content: &str) -> Option<String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Partial {
|
||||
skill: PartialSkill,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct PartialSkill {
|
||||
description: Option<String>,
|
||||
}
|
||||
toml::from_str::<Partial>(content)
|
||||
.ok()
|
||||
.and_then(|p| p.skill.description)
|
||||
}
|
||||
|
||||
/// Extract `ToolCallRecord`s from the agent conversation history.
|
||||
///
|
||||
/// Scans assistant messages for tool call patterns (both JSON and XML formats)
|
||||
/// and returns records for each unique tool invocation.
|
||||
pub fn extract_tool_calls_from_history(
|
||||
history: &[crate::providers::ChatMessage],
|
||||
) -> Vec<ToolCallRecord> {
|
||||
let mut records = Vec::new();
|
||||
|
||||
for msg in history {
|
||||
if msg.role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try parsing as JSON (native tool_calls format).
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&msg.content) {
|
||||
if let Some(tool_calls) = value.get("tool_calls").and_then(|v| v.as_array()) {
|
||||
for call in tool_calls {
|
||||
if let Some(function) = call.get("function") {
|
||||
let name = function
|
||||
.get("name")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let args_str = function
|
||||
.get("arguments")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("{}");
|
||||
let args = serde_json::from_str(args_str).unwrap_or_default();
|
||||
if !name.is_empty() {
|
||||
records.push(ToolCallRecord { name, args });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also try XML tool call format: <tool_name>...</tool_name>
|
||||
// Simple extraction for `<shell>{"command":"..."}</shell>` style tags.
|
||||
let content = &msg.content;
|
||||
let mut pos = 0;
|
||||
while pos < content.len() {
|
||||
if let Some(start) = content[pos..].find('<') {
|
||||
let abs_start = pos + start;
|
||||
if let Some(end) = content[abs_start..].find('>') {
|
||||
let tag = &content[abs_start + 1..abs_start + end];
|
||||
// Skip closing tags and meta tags.
|
||||
if tag.starts_with('/') || tag.starts_with('!') || tag.starts_with('?') {
|
||||
pos = abs_start + end + 1;
|
||||
continue;
|
||||
}
|
||||
let tag_name = tag.split_whitespace().next().unwrap_or(tag);
|
||||
let close_tag = format!("</{tag_name}>");
|
||||
if let Some(close_pos) = content[abs_start + end + 1..].find(&close_tag) {
|
||||
let inner = &content[abs_start + end + 1..abs_start + end + 1 + close_pos];
|
||||
let args: serde_json::Value =
|
||||
serde_json::from_str(inner.trim()).unwrap_or_default();
|
||||
// Only add if it looks like a tool call (not HTML/formatting tags).
|
||||
if tag_name != "tool_result"
|
||||
&& tag_name != "tool_results"
|
||||
&& !tag_name.contains(':')
|
||||
&& args.is_object()
|
||||
&& !args.as_object().map_or(true, |o| o.is_empty())
|
||||
{
|
||||
records.push(ToolCallRecord {
|
||||
name: tag_name.to_string(),
|
||||
args,
|
||||
});
|
||||
}
|
||||
pos = abs_start + end + 1 + close_pos + close_tag.len();
|
||||
} else {
|
||||
pos = abs_start + end + 1;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::embeddings::{EmbeddingProvider, NoopEmbedding};
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ── Slug generation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn slug_basic() {
|
||||
assert_eq!(
|
||||
SkillCreator::generate_slug("Deploy to production"),
|
||||
"deploy-to-production"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_special_characters() {
|
||||
assert_eq!(
|
||||
SkillCreator::generate_slug("Build & test (CI/CD) pipeline!"),
|
||||
"build-test-ci-cd-pipeline"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_max_length() {
|
||||
let long_desc = "a".repeat(100);
|
||||
let slug = SkillCreator::generate_slug(&long_desc);
|
||||
assert!(slug.len() <= 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_leading_trailing_hyphens() {
|
||||
let slug = SkillCreator::generate_slug("---hello world---");
|
||||
assert!(!slug.starts_with('-'));
|
||||
assert!(!slug.ends_with('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_consecutive_spaces() {
|
||||
assert_eq!(SkillCreator::generate_slug("hello world"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_empty_input() {
|
||||
let slug = SkillCreator::generate_slug("");
|
||||
assert!(slug.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_only_symbols() {
|
||||
let slug = SkillCreator::generate_slug("!@#$%^&*()");
|
||||
assert!(slug.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slug_unicode() {
|
||||
let slug = SkillCreator::generate_slug("Deploy cafe app");
|
||||
assert_eq!(slug, "deploy-cafe-app");
|
||||
}
|
||||
|
||||
// ── Slug validation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_slug_valid() {
|
||||
assert!(SkillCreator::validate_slug("deploy-to-production"));
|
||||
assert!(SkillCreator::validate_slug("a"));
|
||||
assert!(SkillCreator::validate_slug("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_slug_invalid() {
|
||||
assert!(!SkillCreator::validate_slug(""));
|
||||
assert!(!SkillCreator::validate_slug("-starts-with-hyphen"));
|
||||
assert!(!SkillCreator::validate_slug("ends-with-hyphen-"));
|
||||
assert!(!SkillCreator::validate_slug("has spaces"));
|
||||
assert!(!SkillCreator::validate_slug("has_underscores"));
|
||||
assert!(!SkillCreator::validate_slug(&"a".repeat(65)));
|
||||
}
|
||||
|
||||
// ── TOML generation ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toml_generation_valid_format() {
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo build"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo test"}),
|
||||
},
|
||||
];
|
||||
let toml_str = SkillCreator::generate_skill_toml(
|
||||
"build-and-test",
|
||||
"Build and test the project",
|
||||
&calls,
|
||||
);
|
||||
|
||||
// Should parse as valid TOML.
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&toml_str).expect("Generated TOML should be valid");
|
||||
let skill = parsed.get("skill").expect("Should have [skill] section");
|
||||
assert_eq!(
|
||||
skill.get("name").and_then(toml::Value::as_str),
|
||||
Some("build-and-test")
|
||||
);
|
||||
assert_eq!(
|
||||
skill.get("author").and_then(toml::Value::as_str),
|
||||
Some("zeroclaw-auto")
|
||||
);
|
||||
assert_eq!(
|
||||
skill.get("version").and_then(toml::Value::as_str),
|
||||
Some("0.1.0")
|
||||
);
|
||||
|
||||
let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
|
||||
assert_eq!(tools.len(), 2);
|
||||
assert_eq!(
|
||||
tools[0].get("command").and_then(toml::Value::as_str),
|
||||
Some("cargo build")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_generation_escapes_quotes() {
|
||||
let calls = vec![ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "echo \"hello\""}),
|
||||
}];
|
||||
let toml_str =
|
||||
SkillCreator::generate_skill_toml("echo-test", "Test \"quoted\" description", &calls);
|
||||
let parsed: toml::Value =
|
||||
toml::from_str(&toml_str).expect("TOML with quotes should be valid");
|
||||
let desc = parsed
|
||||
.get("skill")
|
||||
.and_then(|s| s.get("description"))
|
||||
.and_then(toml::Value::as_str)
|
||||
.unwrap();
|
||||
assert!(desc.contains("quoted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toml_generation_no_command_arg() {
|
||||
let calls = vec![ToolCallRecord {
|
||||
name: "memory_store".into(),
|
||||
args: serde_json::json!({"key": "foo", "value": "bar"}),
|
||||
}];
|
||||
let toml_str = SkillCreator::generate_skill_toml("memory-op", "Store to memory", &calls);
|
||||
let parsed: toml::Value = toml::from_str(&toml_str).expect("TOML should be valid");
|
||||
let tools = parsed.get("tools").and_then(toml::Value::as_array).unwrap();
|
||||
// When no "command" arg exists, falls back to tool name.
|
||||
assert_eq!(
|
||||
tools[0].get("command").and_then(toml::Value::as_str),
|
||||
Some("memory_store")
|
||||
);
|
||||
}
|
||||
|
||||
// ── TOML description extraction ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_description_from_valid_toml() {
|
||||
let content = r#"
|
||||
[skill]
|
||||
name = "test"
|
||||
description = "Auto-generated: Build project"
|
||||
version = "0.1.0"
|
||||
"#;
|
||||
assert_eq!(
|
||||
extract_description_from_toml(content),
|
||||
Some("Auto-generated: Build project".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_description_from_invalid_toml() {
|
||||
assert_eq!(extract_description_from_toml("not valid toml {{"), None);
|
||||
}
|
||||
|
||||
// ── Deduplication ────────────────────────────────────────────
|
||||
|
||||
/// A mock embedding provider that returns deterministic embeddings.
|
||||
///
|
||||
/// The "new" description (first text embedded) always gets `[1, 0, 0]`.
|
||||
/// The "existing" skill description (second text embedded) gets a vector
|
||||
/// whose cosine similarity with `[1, 0, 0]` equals `self.similarity`.
|
||||
struct MockEmbeddingProvider {
|
||||
similarity: f32,
|
||||
call_count: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl MockEmbeddingProvider {
|
||||
fn new(similarity: f32) -> Self {
|
||||
Self {
|
||||
similarity,
|
||||
call_count: std::sync::atomic::AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmbeddingProvider for MockEmbeddingProvider {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
3
|
||||
}
|
||||
async fn embed(&self, texts: &[&str]) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
Ok(texts
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let call = self
|
||||
.call_count
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
if call == 0 {
|
||||
// First call: the "new" description.
|
||||
vec![1.0, 0.0, 0.0]
|
||||
} else {
|
||||
// Subsequent calls: existing skill descriptions.
|
||||
// Produce a vector with the configured cosine similarity to [1,0,0].
|
||||
vec![
|
||||
self.similarity,
|
||||
(1.0 - self.similarity * self.similarity).sqrt(),
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dedup_skips_similar_descriptions() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let skills_dir = dir.path().join("skills").join("existing-skill");
|
||||
tokio::fs::create_dir_all(&skills_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skills_dir.join("SKILL.toml"),
|
||||
r#"
|
||||
[skill]
|
||||
name = "existing-skill"
|
||||
description = "Auto-generated: Build the project"
|
||||
version = "0.1.0"
|
||||
author = "zeroclaw-auto"
|
||||
tags = ["auto-generated"]
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
|
||||
// High similarity provider -> should detect as duplicate.
|
||||
let provider = MockEmbeddingProvider::new(0.95);
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config.clone());
|
||||
assert!(creator
|
||||
.is_duplicate("Build the project", &provider)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// Low similarity provider -> not a duplicate.
|
||||
let provider_low = MockEmbeddingProvider::new(0.3);
|
||||
let creator2 = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
assert!(!creator2
|
||||
.is_duplicate("Completely different task", &provider_low)
|
||||
.await
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
// ── LRU eviction ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_eviction_removes_oldest() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 2,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
|
||||
let skills_dir = dir.path().join("skills");
|
||||
|
||||
// Create two auto-generated skills with different timestamps.
|
||||
for (i, name) in ["old-skill", "new-skill"].iter().enumerate() {
|
||||
let skill_dir = skills_dir.join(name);
|
||||
tokio::fs::create_dir_all(&skill_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skill_dir.join("SKILL.toml"),
|
||||
format!(
|
||||
r#"[skill]
|
||||
name = "{name}"
|
||||
description = "Auto-generated: Skill {i}"
|
||||
version = "0.1.0"
|
||||
author = "zeroclaw-auto"
|
||||
tags = ["auto-generated"]
|
||||
"#
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Small delay to ensure different timestamps.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
creator.enforce_lru_limit().await.unwrap();
|
||||
|
||||
// The oldest skill should have been removed.
|
||||
assert!(!skills_dir.join("old-skill").exists());
|
||||
assert!(skills_dir.join("new-skill").exists());
|
||||
}
|
||||
|
||||
// ── End-to-end: create_from_execution ────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
};
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "ls"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "pwd"}),
|
||||
},
|
||||
];
|
||||
let result = creator
|
||||
.create_from_execution("List files", &calls, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_insufficient_steps() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "ls"}),
|
||||
}];
|
||||
let result = creator
|
||||
.create_from_execution("List files", &calls, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_success() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo build"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo test"}),
|
||||
},
|
||||
];
|
||||
|
||||
// Use noop embedding (no deduplication).
|
||||
let noop = NoopEmbedding;
|
||||
let result = creator
|
||||
.create_from_execution("Build and test", &calls, Some(&noop))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, Some("build-and-test".into()));
|
||||
|
||||
// Verify the skill directory and TOML were created.
|
||||
let skill_dir = dir.path().join("skills").join("build-and-test");
|
||||
assert!(skill_dir.exists());
|
||||
let toml_content = tokio::fs::read_to_string(skill_dir.join("SKILL.toml"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(toml_content.contains("build-and-test"));
|
||||
assert!(toml_content.contains("zeroclaw-auto"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_from_execution_with_dedup() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let config = SkillCreationConfig {
|
||||
enabled: true,
|
||||
max_skills: 500,
|
||||
similarity_threshold: 0.85,
|
||||
};
|
||||
|
||||
// First, create an existing skill.
|
||||
let skills_dir = dir.path().join("skills").join("existing");
|
||||
tokio::fs::create_dir_all(&skills_dir).await.unwrap();
|
||||
tokio::fs::write(
|
||||
skills_dir.join("SKILL.toml"),
|
||||
r#"[skill]
|
||||
name = "existing"
|
||||
description = "Auto-generated: Build and test"
|
||||
version = "0.1.0"
|
||||
author = "zeroclaw-auto"
|
||||
tags = ["auto-generated"]
|
||||
"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// High similarity provider -> should skip.
|
||||
let provider = MockEmbeddingProvider::new(0.95);
|
||||
let creator = SkillCreator::new(dir.path().to_path_buf(), config);
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo build"}),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "shell".into(),
|
||||
args: serde_json::json!({"command": "cargo test"}),
|
||||
},
|
||||
];
|
||||
let result = creator
|
||||
.create_from_execution("Build and test", &calls, Some(&provider))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
// ── Tool call extraction from history ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_from_empty_history() {
|
||||
let history = vec![];
|
||||
let records = extract_tool_calls_from_history(&history);
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_from_user_messages_only() {
|
||||
use crate::providers::ChatMessage;
|
||||
let history = vec![ChatMessage::user("hello"), ChatMessage::user("world")];
|
||||
let records = extract_tool_calls_from_history(&history);
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
// ── Fuzz-like tests for slug ─────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn slug_fuzz_various_inputs() {
|
||||
let inputs = [
|
||||
"",
|
||||
" ",
|
||||
"---",
|
||||
"a",
|
||||
"hello world!",
|
||||
"UPPER CASE",
|
||||
"with-hyphens-already",
|
||||
"with__underscores",
|
||||
"123 numbers 456",
|
||||
"emoji: cafe",
|
||||
&"x".repeat(200),
|
||||
"a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p-q-r-s-t-u-v-w-x-y-z-0-1-2-3-4-5",
|
||||
];
|
||||
|
||||
for input in &inputs {
|
||||
let slug = SkillCreator::generate_slug(input);
|
||||
// Slug should always pass validation (or be empty for degenerate input).
|
||||
if !slug.is_empty() {
|
||||
assert!(
|
||||
SkillCreator::validate_slug(&slug),
|
||||
"Generated slug '{slug}' from '{input}' failed validation"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fuzz-like tests for TOML generation ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn toml_fuzz_various_inputs() {
|
||||
let descriptions = [
|
||||
"simple task",
|
||||
"task with \"quotes\" and \\ backslashes",
|
||||
"task with\nnewlines\r\nand tabs\there",
|
||||
"",
|
||||
&"long ".repeat(100),
|
||||
];
|
||||
|
||||
let args_variants = [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"command": "echo hello"}),
|
||||
serde_json::json!({"command": "echo \"hello world\"", "extra": 42}),
|
||||
];
|
||||
|
||||
for desc in &descriptions {
|
||||
for args in &args_variants {
|
||||
let calls = vec![
|
||||
ToolCallRecord {
|
||||
name: "tool1".into(),
|
||||
args: args.clone(),
|
||||
},
|
||||
ToolCallRecord {
|
||||
name: "tool2".into(),
|
||||
args: args.clone(),
|
||||
},
|
||||
];
|
||||
let toml_str = SkillCreator::generate_skill_toml("test-slug", desc, &calls);
|
||||
// Must always produce valid TOML.
|
||||
let _parsed: toml::Value = toml::from_str(&toml_str)
|
||||
.unwrap_or_else(|e| panic!("Invalid TOML for desc '{desc}': {e}\n{toml_str}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
-23
@@ -7,6 +7,8 @@ use std::process::Command;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
mod audit;
|
||||
#[cfg(feature = "skill-creation")]
|
||||
pub mod creator;
|
||||
|
||||
const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills";
|
||||
const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync";
|
||||
@@ -83,7 +85,7 @@ fn default_version() -> String {
|
||||
|
||||
/// Load all skills from the workspace skills directory
|
||||
pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
|
||||
load_skills_with_open_skills_config(workspace_dir, None, None)
|
||||
load_skills_with_open_skills_config(workspace_dir, None, None, None)
|
||||
}
|
||||
|
||||
/// Load skills using runtime config values (preferred at runtime).
|
||||
@@ -92,6 +94,21 @@ pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Con
|
||||
workspace_dir,
|
||||
Some(config.skills.open_skills_enabled),
|
||||
config.skills.open_skills_dir.as_deref(),
|
||||
Some(config.skills.allow_scripts),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,25 +116,27 @@ fn load_skills_with_open_skills_config(
|
||||
workspace_dir: &Path,
|
||||
config_open_skills_enabled: Option<bool>,
|
||||
config_open_skills_dir: Option<&str>,
|
||||
config_allow_scripts: Option<bool>,
|
||||
) -> Vec<Skill> {
|
||||
let mut skills = Vec::new();
|
||||
let allow_scripts = config_allow_scripts.unwrap_or(false);
|
||||
|
||||
if let Some(open_skills_dir) =
|
||||
ensure_open_skills_repo(config_open_skills_enabled, config_open_skills_dir)
|
||||
{
|
||||
skills.extend(load_open_skills(&open_skills_dir));
|
||||
skills.extend(load_open_skills(&open_skills_dir, allow_scripts));
|
||||
}
|
||||
|
||||
skills.extend(load_workspace_skills(workspace_dir));
|
||||
skills.extend(load_workspace_skills(workspace_dir, allow_scripts));
|
||||
skills
|
||||
}
|
||||
|
||||
fn load_workspace_skills(workspace_dir: &Path) -> Vec<Skill> {
|
||||
fn load_workspace_skills(workspace_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
|
||||
let skills_dir = workspace_dir.join("skills");
|
||||
load_skills_from_directory(&skills_dir)
|
||||
load_skills_from_directory(&skills_dir, allow_scripts)
|
||||
}
|
||||
|
||||
fn load_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
fn load_skills_from_directory(skills_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
|
||||
if !skills_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -134,7 +153,10 @@ fn load_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
continue;
|
||||
}
|
||||
|
||||
match audit::audit_skill_directory(&path) {
|
||||
match audit::audit_skill_directory_with_options(
|
||||
&path,
|
||||
audit::SkillAuditOptions { allow_scripts },
|
||||
) {
|
||||
Ok(report) if report.is_clean() => {}
|
||||
Ok(report) => {
|
||||
tracing::warn!(
|
||||
@@ -181,7 +203,7 @@ fn finalize_open_skill(mut skill: Skill) -> Skill {
|
||||
skill
|
||||
}
|
||||
|
||||
fn load_open_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
fn load_open_skills_from_directory(skills_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
|
||||
if !skills_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -198,7 +220,10 @@ fn load_open_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
continue;
|
||||
}
|
||||
|
||||
match audit::audit_skill_directory(&path) {
|
||||
match audit::audit_skill_directory_with_options(
|
||||
&path,
|
||||
audit::SkillAuditOptions { allow_scripts },
|
||||
) {
|
||||
Ok(report) if report.is_clean() => {}
|
||||
Ok(report) => {
|
||||
tracing::warn!(
|
||||
@@ -234,13 +259,13 @@ fn load_open_skills_from_directory(skills_dir: &Path) -> Vec<Skill> {
|
||||
skills
|
||||
}
|
||||
|
||||
fn load_open_skills(repo_dir: &Path) -> Vec<Skill> {
|
||||
fn load_open_skills(repo_dir: &Path, allow_scripts: bool) -> Vec<Skill> {
|
||||
// Modern open-skills layout stores skill packages in `skills/<name>/SKILL.md`.
|
||||
// Prefer that structure to avoid treating repository docs (e.g. CONTRIBUTING.md)
|
||||
// as executable skills.
|
||||
let nested_skills_dir = repo_dir.join("skills");
|
||||
if nested_skills_dir.is_dir() {
|
||||
return load_open_skills_from_directory(&nested_skills_dir);
|
||||
return load_open_skills_from_directory(&nested_skills_dir, allow_scripts);
|
||||
}
|
||||
|
||||
let mut skills = Vec::new();
|
||||
@@ -672,7 +697,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",
|
||||
),
|
||||
};
|
||||
@@ -839,8 +865,14 @@ fn detect_newly_installed_directory(
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce_skill_security_audit(skill_path: &Path) -> Result<audit::SkillAuditReport> {
|
||||
let report = audit::audit_skill_directory(skill_path)?;
|
||||
fn enforce_skill_security_audit(
|
||||
skill_path: &Path,
|
||||
allow_scripts: bool,
|
||||
) -> Result<audit::SkillAuditReport> {
|
||||
let report = audit::audit_skill_directory_with_options(
|
||||
skill_path,
|
||||
audit::SkillAuditOptions { allow_scripts },
|
||||
)?;
|
||||
if report.is_clean() {
|
||||
return Ok(report);
|
||||
}
|
||||
@@ -902,7 +934,11 @@ fn copy_dir_recursive_secure(src: &Path, dest: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install_local_skill_source(source: &str, skills_path: &Path) -> Result<(PathBuf, usize)> {
|
||||
fn install_local_skill_source(
|
||||
source: &str,
|
||||
skills_path: &Path,
|
||||
allow_scripts: bool,
|
||||
) -> Result<(PathBuf, usize)> {
|
||||
let source_path = PathBuf::from(source);
|
||||
if !source_path.exists() {
|
||||
anyhow::bail!("Source path does not exist: {source}");
|
||||
@@ -911,7 +947,7 @@ fn install_local_skill_source(source: &str, skills_path: &Path) -> Result<(PathB
|
||||
let source_path = source_path
|
||||
.canonicalize()
|
||||
.with_context(|| format!("failed to canonicalize source path {source}"))?;
|
||||
let _ = enforce_skill_security_audit(&source_path)?;
|
||||
let _ = enforce_skill_security_audit(&source_path, allow_scripts)?;
|
||||
|
||||
let name = source_path
|
||||
.file_name()
|
||||
@@ -926,7 +962,7 @@ fn install_local_skill_source(source: &str, skills_path: &Path) -> Result<(PathB
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
match enforce_skill_security_audit(&dest) {
|
||||
match enforce_skill_security_audit(&dest, allow_scripts) {
|
||||
Ok(report) => Ok((dest, report.files_scanned)),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_dir_all(&dest);
|
||||
@@ -935,7 +971,11 @@ fn install_local_skill_source(source: &str, skills_path: &Path) -> Result<(PathB
|
||||
}
|
||||
}
|
||||
|
||||
fn install_git_skill_source(source: &str, skills_path: &Path) -> Result<(PathBuf, usize)> {
|
||||
fn install_git_skill_source(
|
||||
source: &str,
|
||||
skills_path: &Path,
|
||||
allow_scripts: bool,
|
||||
) -> Result<(PathBuf, usize)> {
|
||||
let before = snapshot_skill_children(skills_path)?;
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["clone", "--depth", "1", source])
|
||||
@@ -948,7 +988,7 @@ fn install_git_skill_source(source: &str, skills_path: &Path) -> Result<(PathBuf
|
||||
|
||||
let installed_dir = detect_newly_installed_directory(skills_path, &before)?;
|
||||
remove_git_metadata(&installed_dir)?;
|
||||
match enforce_skill_security_audit(&installed_dir) {
|
||||
match enforce_skill_security_audit(&installed_dir, allow_scripts) {
|
||||
Ok(report) => Ok((installed_dir, report.files_scanned)),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_dir_all(&installed_dir);
|
||||
@@ -1012,7 +1052,12 @@ pub fn handle_command(command: crate::SkillCommands, config: &crate::config::Con
|
||||
anyhow::bail!("Skill source or installed skill not found: {source}");
|
||||
}
|
||||
|
||||
let report = audit::audit_skill_directory(&target)?;
|
||||
let report = audit::audit_skill_directory_with_options(
|
||||
&target,
|
||||
audit::SkillAuditOptions {
|
||||
allow_scripts: config.skills.allow_scripts,
|
||||
},
|
||||
)?;
|
||||
if report.is_clean() {
|
||||
println!(
|
||||
" {} Skill audit passed for {} ({} files scanned).",
|
||||
@@ -1041,7 +1086,7 @@ pub fn handle_command(command: crate::SkillCommands, config: &crate::config::Con
|
||||
|
||||
if is_git_source(&source) {
|
||||
let (installed_dir, files_scanned) =
|
||||
install_git_skill_source(&source, &skills_path)
|
||||
install_git_skill_source(&source, &skills_path, config.skills.allow_scripts)
|
||||
.with_context(|| format!("failed to install git skill source: {source}"))?;
|
||||
println!(
|
||||
" {} Skill installed and audited: {} ({} files scanned)",
|
||||
@@ -1050,8 +1095,11 @@ pub fn handle_command(command: crate::SkillCommands, config: &crate::config::Con
|
||||
files_scanned
|
||||
);
|
||||
} else {
|
||||
let (dest, files_scanned) = install_local_skill_source(&source, &skills_path)
|
||||
.with_context(|| format!("failed to install local skill source: {source}"))?;
|
||||
let (dest, files_scanned) =
|
||||
install_local_skill_source(&source, &skills_path, config.skills.allow_scripts)
|
||||
.with_context(|| {
|
||||
format!("failed to install local skill source: {source}")
|
||||
})?;
|
||||
println!(
|
||||
" {} Skill installed and audited: {} ({} files scanned)",
|
||||
console::style("✓").green().bold(),
|
||||
@@ -1265,6 +1313,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>"));
|
||||
|
||||
+108
-3
@@ -1,6 +1,8 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{self, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
use crate::cron::{
|
||||
self, deserialize_maybe_stringified, DeliveryConfig, JobType, Schedule, SessionTarget,
|
||||
};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -128,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.",
|
||||
@@ -176,7 +183,7 @@ impl Tool for CronAddTool {
|
||||
}
|
||||
|
||||
let schedule = match args.get("schedule") {
|
||||
Some(v) => match serde_json::from_value::<Schedule>(v.clone()) {
|
||||
Some(v) => match deserialize_maybe_stringified::<Schedule>(v) {
|
||||
Ok(schedule) => schedule,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
@@ -286,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()) {
|
||||
@@ -314,6 +334,7 @@ impl Tool for CronAddTool {
|
||||
model,
|
||||
delivery,
|
||||
delete_after_run,
|
||||
allowed_tools,
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -327,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,
|
||||
}),
|
||||
@@ -511,6 +533,63 @@ mod tests {
|
||||
assert!(approved.success, "{:?}", approved.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_schedule_passed_as_json_string() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let cfg = test_config(&tmp).await;
|
||||
let tool = CronAddTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
// Simulate the LLM double-serializing the schedule: the value arrives
|
||||
// as a JSON string containing a JSON object, rather than an object.
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"schedule": r#"{"kind":"cron","expr":"*/5 * * * *"}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo string-schedule"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
assert!(result.output.contains("next_run"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_stringified_interval_schedule() {
|
||||
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": r#"{"kind":"every","every_ms":60000}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo interval"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accepts_stringified_schedule_with_timezone() {
|
||||
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": r#"{"kind":"cron","expr":"*/30 9-15 * * 1-5","tz":"Asia/Shanghai"}"#,
|
||||
"job_type": "shell",
|
||||
"command": "echo tz-test"
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.success, "{:?}", result.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_schedule() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -553,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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{self, CronJobPatch};
|
||||
use crate::cron::{self, deserialize_maybe_stringified, CronJobPatch};
|
||||
use crate::security::SecurityPolicy;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
@@ -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"],
|
||||
@@ -202,7 +207,7 @@ impl Tool for CronUpdateTool {
|
||||
}
|
||||
};
|
||||
|
||||
let patch = match serde_json::from_value::<CronJobPatch>(patch_val) {
|
||||
let patch = match deserialize_maybe_stringified::<CronJobPatch>(&patch_val) {
|
||||
Ok(patch) => patch,
|
||||
Err(e) => {
|
||||
return Ok(ToolResult {
|
||||
@@ -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()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+266
-10
@@ -1,6 +1,6 @@
|
||||
use super::traits::{Tool, ToolResult};
|
||||
use crate::agent::loop_::run_tool_call_loop;
|
||||
use crate::config::DelegateAgentConfig;
|
||||
use crate::config::{DelegateAgentConfig, DelegateToolConfig};
|
||||
use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
|
||||
use crate::providers::{self, ChatMessage, Provider};
|
||||
use crate::security::policy::ToolOperation;
|
||||
@@ -12,11 +12,6 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default timeout for sub-agent provider calls.
|
||||
const DELEGATE_TIMEOUT_SECS: u64 = 120;
|
||||
/// Default timeout for agentic sub-agent runs.
|
||||
const DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Tool that delegates a subtask to a named agent with a different
|
||||
/// provider/model configuration. Enables multi-agent workflows where
|
||||
/// a primary agent can hand off specialized work (research, coding,
|
||||
@@ -34,6 +29,8 @@ pub struct DelegateTool {
|
||||
parent_tools: Arc<RwLock<Vec<Arc<dyn Tool>>>>,
|
||||
/// Inherited multimodal handling config for sub-agent loops.
|
||||
multimodal_config: crate::config::MultimodalConfig,
|
||||
/// Global delegate tool config providing default timeout values.
|
||||
delegate_config: DelegateToolConfig,
|
||||
}
|
||||
|
||||
impl DelegateTool {
|
||||
@@ -64,6 +61,7 @@ impl DelegateTool {
|
||||
depth: 0,
|
||||
parent_tools: Arc::new(RwLock::new(Vec::new())),
|
||||
multimodal_config: crate::config::MultimodalConfig::default(),
|
||||
delegate_config: DelegateToolConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +98,7 @@ impl DelegateTool {
|
||||
depth,
|
||||
parent_tools: Arc::new(RwLock::new(Vec::new())),
|
||||
multimodal_config: crate::config::MultimodalConfig::default(),
|
||||
delegate_config: DelegateToolConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +114,12 @@ impl DelegateTool {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach global delegate tool configuration for default timeout values.
|
||||
pub fn with_delegate_config(mut self, config: DelegateToolConfig) -> Self {
|
||||
self.delegate_config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Return a shared handle to the parent tools list.
|
||||
/// Callers can push additional tools (e.g. MCP wrappers) after construction.
|
||||
pub fn parent_tools_handle(&self) -> Arc<RwLock<Vec<Arc<dyn Tool>>>> {
|
||||
@@ -296,8 +301,11 @@ impl Tool for DelegateTool {
|
||||
}
|
||||
|
||||
// Wrap the provider call in a timeout to prevent indefinite blocking
|
||||
let timeout_secs = agent_config
|
||||
.timeout_secs
|
||||
.unwrap_or(self.delegate_config.timeout_secs);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(DELEGATE_TIMEOUT_SECS),
|
||||
Duration::from_secs(timeout_secs),
|
||||
provider.chat_with_system(
|
||||
agent_config.system_prompt.as_deref(),
|
||||
&full_prompt,
|
||||
@@ -314,7 +322,7 @@ impl Tool for DelegateTool {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Agent '{agent_name}' timed out after {DELEGATE_TIMEOUT_SECS}s"
|
||||
"Agent '{agent_name}' timed out after {timeout_secs}s"
|
||||
)),
|
||||
});
|
||||
}
|
||||
@@ -401,8 +409,11 @@ impl DelegateTool {
|
||||
|
||||
let noop_observer = NoopObserver;
|
||||
|
||||
let agentic_timeout_secs = agent_config
|
||||
.agentic_timeout_secs
|
||||
.unwrap_or(self.delegate_config.agentic_timeout_secs);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(DELEGATE_AGENTIC_TIMEOUT_SECS),
|
||||
Duration::from_secs(agentic_timeout_secs),
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
&mut history,
|
||||
@@ -414,6 +425,7 @@ impl DelegateTool {
|
||||
true,
|
||||
None,
|
||||
"delegate",
|
||||
None,
|
||||
&self.multimodal_config,
|
||||
agent_config.max_iterations,
|
||||
None,
|
||||
@@ -422,6 +434,7 @@ impl DelegateTool {
|
||||
&[],
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
@@ -453,7 +466,7 @@ impl DelegateTool {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(format!(
|
||||
"Agent '{agent_name}' timed out after {DELEGATE_AGENTIC_TIMEOUT_SECS}s"
|
||||
"Agent '{agent_name}' timed out after {agentic_timeout_secs}s"
|
||||
)),
|
||||
}),
|
||||
}
|
||||
@@ -508,6 +521,9 @@ impl Observer for NoopObserver {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::{
|
||||
DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS, DEFAULT_DELEGATE_TIMEOUT_SECS,
|
||||
};
|
||||
use crate::providers::{ChatRequest, ChatResponse, ToolCall};
|
||||
use crate::security::{AutonomyLevel, SecurityPolicy};
|
||||
use anyhow::anyhow;
|
||||
@@ -530,6 +546,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents.insert(
|
||||
@@ -544,6 +562,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
agents
|
||||
@@ -697,6 +717,8 @@ mod tests {
|
||||
agentic: true,
|
||||
allowed_tools,
|
||||
max_iterations,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,6 +827,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
@@ -911,6 +935,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
@@ -946,6 +972,8 @@ mod tests {
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
@@ -1220,4 +1248,232 @@ mod tests {
|
||||
handle.write().push(Arc::new(FakeMcpTool));
|
||||
assert_eq!(handle.read().len(), 2);
|
||||
}
|
||||
|
||||
// ── Configurable timeout tests ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_timeout_values_used_when_config_unset() {
|
||||
let config = DelegateAgentConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "llama3".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
};
|
||||
assert_eq!(
|
||||
config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
|
||||
120
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.agentic_timeout_secs
|
||||
.unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_timeout_values_are_respected() {
|
||||
let config = DelegateAgentConfig {
|
||||
provider: "ollama".to_string(),
|
||||
model: "llama3".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(60),
|
||||
agentic_timeout_secs: Some(600),
|
||||
};
|
||||
assert_eq!(
|
||||
config.timeout_secs.unwrap_or(DEFAULT_DELEGATE_TIMEOUT_SECS),
|
||||
60
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.agentic_timeout_secs
|
||||
.unwrap_or(DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS),
|
||||
600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_deserialization_defaults_to_none() {
|
||||
let toml_str = r#"
|
||||
provider = "ollama"
|
||||
model = "llama3"
|
||||
"#;
|
||||
let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(config.timeout_secs.is_none());
|
||||
assert!(config.agentic_timeout_secs.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_deserialization_with_custom_values() {
|
||||
let toml_str = r#"
|
||||
provider = "ollama"
|
||||
model = "llama3"
|
||||
timeout_secs = 45
|
||||
agentic_timeout_secs = 900
|
||||
"#;
|
||||
let config: DelegateAgentConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.timeout_secs, Some(45));
|
||||
assert_eq!(config.agentic_timeout_secs, Some(900));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_zero_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(0),
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("timeout_secs must be greater than 0"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_zero_agentic_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: Some(0),
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("agentic_timeout_secs must be greater than 0"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_excessive_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(7200),
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("exceeds max 3600"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_rejects_excessive_agentic_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"bad".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: Some(5000),
|
||||
},
|
||||
);
|
||||
let err = config.validate().unwrap_err();
|
||||
assert!(
|
||||
format!("{err}").contains("exceeds max 3600"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_accepts_max_boundary_timeout() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"ok".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: Some(3600),
|
||||
agentic_timeout_secs: Some(3600),
|
||||
},
|
||||
);
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_validation_accepts_none_timeouts() {
|
||||
let mut config = crate::config::Config::default();
|
||||
config.agents.insert(
|
||||
"ok".into(),
|
||||
DelegateAgentConfig {
|
||||
provider: "ollama".into(),
|
||||
model: "llama3".into(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
agentic: false,
|
||||
allowed_tools: Vec::new(),
|
||||
max_iterations: 10,
|
||||
timeout_secs: None,
|
||||
agentic_timeout_secs: None,
|
||||
},
|
||||
);
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user