Merge origin/master into master
Made-with: Cursor
This commit is contained in:
commit
0931b140cf
@ -59,6 +59,7 @@ PROVIDER=openrouter
|
||||
# ZAI_API_KEY=...
|
||||
# SYNTHETIC_API_KEY=...
|
||||
# OPENCODE_API_KEY=...
|
||||
# OPENCODE_GO_API_KEY=...
|
||||
# VERCEL_API_KEY=...
|
||||
# CLOUDFLARE_API_KEY=...
|
||||
|
||||
|
||||
50
.github/CODEOWNERS
vendored
50
.github/CODEOWNERS
vendored
@ -1,32 +1,32 @@
|
||||
# Default owner for all files
|
||||
* @theonlyhennygod @SimianAstronaut7
|
||||
* @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
|
||||
# Important functional modules
|
||||
/src/agent/** @theonlyhennygod @SimianAstronaut7
|
||||
/src/providers/** @theonlyhennygod @SimianAstronaut7
|
||||
/src/channels/** @theonlyhennygod @SimianAstronaut7
|
||||
/src/tools/** @theonlyhennygod @SimianAstronaut7
|
||||
/src/gateway/** @theonlyhennygod @SimianAstronaut7
|
||||
/src/runtime/** @theonlyhennygod @SimianAstronaut7
|
||||
/src/memory/** @theonlyhennygod @SimianAstronaut7
|
||||
/Cargo.toml @theonlyhennygod @SimianAstronaut7
|
||||
/Cargo.lock @theonlyhennygod @SimianAstronaut7
|
||||
/src/agent/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/src/providers/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/src/channels/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/src/tools/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/src/gateway/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/src/runtime/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/src/memory/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/Cargo.toml @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/Cargo.lock @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
|
||||
# Security / tests / CI-CD ownership
|
||||
/src/security/** @theonlyhennygod @SimianAstronaut7
|
||||
/tests/** @theonlyhennygod @SimianAstronaut7
|
||||
/.github/** @theonlyhennygod @SimianAstronaut7
|
||||
/.github/workflows/** @theonlyhennygod @SimianAstronaut7
|
||||
/.github/codeql/** @theonlyhennygod @SimianAstronaut7
|
||||
/.github/dependabot.yml @theonlyhennygod @SimianAstronaut7
|
||||
/SECURITY.md @theonlyhennygod @SimianAstronaut7
|
||||
/docs/actions-source-policy.md @theonlyhennygod @SimianAstronaut7
|
||||
/docs/ci-map.md @theonlyhennygod @SimianAstronaut7
|
||||
/src/security/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/tests/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/.github/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/.github/workflows/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/.github/codeql/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/.github/dependabot.yml @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/SECURITY.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/docs/actions-source-policy.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/docs/ci-map.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
|
||||
# Docs & governance
|
||||
/docs/** @theonlyhennygod @SimianAstronaut7
|
||||
/AGENTS.md @theonlyhennygod @SimianAstronaut7
|
||||
/CLAUDE.md @theonlyhennygod @SimianAstronaut7
|
||||
/CONTRIBUTING.md @theonlyhennygod @SimianAstronaut7
|
||||
/docs/pr-workflow.md @theonlyhennygod @SimianAstronaut7
|
||||
/docs/reviewer-playbook.md @theonlyhennygod @SimianAstronaut7
|
||||
/docs/** @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/AGENTS.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/CLAUDE.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/CONTRIBUTING.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/docs/pr-workflow.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
/docs/reviewer-playbook.md @theonlyhennygod @JordanTheJet @SimianAstronaut7
|
||||
|
||||
35
.github/workflows/checks-on-pr.yml
vendored
35
.github/workflows/checks-on-pr.yml
vendored
@ -96,7 +96,7 @@ jobs:
|
||||
|
||||
- name: Build release
|
||||
shell: bash
|
||||
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
run: cargo build --profile ci --locked --target ${{ matrix.target }}
|
||||
env:
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
@ -124,12 +124,30 @@ jobs:
|
||||
- name: Check licenses and sources
|
||||
run: cargo deny check licenses sources
|
||||
|
||||
check-32bit:
|
||||
name: "Check (32-bit)"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
targets: i686-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
- name: Install 32-bit libs
|
||||
run: sudo apt-get update && sudo apt-get install -y gcc-multilib
|
||||
- name: Ensure web/dist placeholder exists
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Cargo check (32-bit, no default features)
|
||||
run: cargo check --target i686-unknown-linux-gnu --no-default-features
|
||||
|
||||
# Composite status check — branch protection only needs to require this
|
||||
# single job instead of tracking every matrix leg individually.
|
||||
gate:
|
||||
name: CI Required Gate
|
||||
if: always()
|
||||
needs: [lint, test, build, security]
|
||||
needs: [lint, test, build, security, check-32bit]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check upstream job results
|
||||
@ -138,3 +156,16 @@ jobs:
|
||||
echo "::error::One or more upstream jobs failed or were cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
security-gate:
|
||||
name: Security Required Gate
|
||||
if: always()
|
||||
needs: [security]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check security job result
|
||||
run: |
|
||||
if [[ "${{ needs.security.result }}" != "success" ]]; then
|
||||
echo "::error::Security audit failed or was cancelled"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
166
.github/workflows/ci-run.yml
vendored
Normal file
166
.github/workflows/ci-run.yml
vendored
Normal file
@ -0,0 +1,166 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
|
||||
- name: Ensure web/dist placeholder exists
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
lint-strict-delta:
|
||||
name: Strict Delta Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
components: clippy
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
|
||||
- name: Ensure web/dist placeholder exists
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
|
||||
- name: Run strict delta lint gate
|
||||
run: bash scripts/ci/rust_strict_delta_gate.sh
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
|
||||
- name: Ensure web/dist placeholder exists
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
|
||||
- name: Install mold linker
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y mold
|
||||
|
||||
- name: Install cargo-nextest
|
||||
run: curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin
|
||||
|
||||
- name: Run tests
|
||||
run: cargo nextest run --locked
|
||||
env:
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 40
|
||||
needs: [lint]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
|
||||
with:
|
||||
toolchain: 1.92.0
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
|
||||
- name: Install mold linker
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y mold
|
||||
|
||||
- name: Ensure web/dist placeholder exists
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
|
||||
- name: Build release
|
||||
shell: bash
|
||||
run: cargo build --profile ci --locked --target ${{ matrix.target }}
|
||||
env:
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C link-arg=-fuse-ld=mold"
|
||||
|
||||
docs-quality:
|
||||
name: Docs Quality
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4
|
||||
with:
|
||||
node-version: 20
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Run docs quality gate
|
||||
run: bash scripts/ci/docs_quality_gate.sh
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
|
||||
|
||||
# Composite status check — branch protection requires this single job.
|
||||
gate:
|
||||
name: CI Required Gate
|
||||
if: always()
|
||||
needs: [lint, lint-strict-delta, test, build, docs-quality]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check upstream job results
|
||||
env:
|
||||
HAS_FAILURE: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
|
||||
run: |
|
||||
if [[ "$HAS_FAILURE" == "true" ]]; then
|
||||
echo "::error::One or more upstream jobs failed or were cancelled"
|
||||
exit 1
|
||||
fi
|
||||
4
.github/workflows/master-branch-flow.md
vendored
4
.github/workflows/master-branch-flow.md
vendored
@ -12,7 +12,7 @@ Use this with:
|
||||
|
||||
ZeroClaw uses a single default branch: `master`. All contributor PRs target `master` directly. There is no `dev` or promotion branch.
|
||||
|
||||
Current maintainers with PR approval authority: `theonlyhennygod` and `SimianAstronaut7`.
|
||||
Current maintainers with PR approval authority: `theonlyhennygod`, `JordanTheJet`, and `SimianAstronaut7`.
|
||||
|
||||
## Active Workflows
|
||||
|
||||
@ -43,7 +43,7 @@ Current maintainers with PR approval authority: `theonlyhennygod` and `SimianAst
|
||||
- `security` job: runs `cargo audit` and `cargo deny check licenses sources`.
|
||||
- Concurrency group cancels in-progress runs for the same PR on new pushes.
|
||||
3. All jobs must pass before merge.
|
||||
4. Maintainer (`theonlyhennygod` or `SimianAstronaut7`) merges PR once checks and review policy are satisfied.
|
||||
4. Maintainer (`theonlyhennygod`, `JordanTheJet`, or `SimianAstronaut7`) merges PR once checks and review policy are satisfied.
|
||||
5. Merge emits a `push` event on `master` (see section 2).
|
||||
|
||||
### 2) Push to `master` (including after merge)
|
||||
|
||||
12
.github/workflows/release-beta-on-push.yml
vendored
12
.github/workflows/release-beta-on-push.yml
vendored
@ -9,7 +9,8 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@ -92,7 +93,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
if: runner.os != 'Windows'
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist/
|
||||
@ -133,12 +134,10 @@ jobs:
|
||||
name: Publish Beta Release
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: zeroclaw-*
|
||||
path: artifacts
|
||||
@ -166,9 +165,6 @@ jobs:
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
|
||||
12
.github/workflows/release-stable-manual.yml
vendored
12
.github/workflows/release-stable-manual.yml
vendored
@ -13,7 +13,8 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@ -110,7 +111,7 @@ jobs:
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
if: runner.os != 'Windows'
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: web-dist
|
||||
path: web/dist/
|
||||
@ -151,12 +152,10 @@ jobs:
|
||||
name: Publish Stable Release
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: zeroclaw-*
|
||||
path: artifacts
|
||||
@ -184,9 +183,6 @@ jobs:
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -35,5 +35,9 @@ credentials.json
|
||||
# Skill eval workspaces (test outputs, transcripts, grading)
|
||||
.claude/skills/*-workspace/
|
||||
|
||||
# Local state backups
|
||||
.local-state-backups/
|
||||
*.local-state-backup/
|
||||
|
||||
# Coverage artifacts
|
||||
lcov.info
|
||||
|
||||
@ -2,6 +2,21 @@
|
||||
|
||||
Thanks for your interest in contributing to ZeroClaw! This guide will help you get started.
|
||||
|
||||
## Branching Model
|
||||
|
||||
> **Important — `master` is the default branch.**
|
||||
>
|
||||
> ZeroClaw uses **`master`** as its single source-of-truth branch. The `main` branch has been removed.
|
||||
>
|
||||
> Previously, some documentation and scripts referenced a `main` branch, which caused 404 errors and contributor confusion (see [#2929](https://github.com/zeroclaw-labs/zeroclaw/issues/2929), [#3061](https://github.com/zeroclaw-labs/zeroclaw/issues/3061), [#3194](https://github.com/zeroclaw-labs/zeroclaw/pull/3194)). As of March 2026, all references have been corrected and the `main` branch deleted.
|
||||
>
|
||||
> **How contributors should work:**
|
||||
> 1. Fork the repository
|
||||
> 2. Create a `feat/*` or `fix/*` branch from `master`
|
||||
> 3. Open a PR targeting `master`
|
||||
>
|
||||
> Do **not** create or push to a `main` branch.
|
||||
|
||||
## First-Time Contributors
|
||||
|
||||
Welcome — contributions of all sizes are valued. If this is your first contribution, here is how to get started:
|
||||
@ -15,7 +30,7 @@ Welcome — contributions of all sizes are valued. If this is your first contrib
|
||||
|
||||
3. **Follow the fork → branch → change → test → PR workflow:**
|
||||
- Fork the repository and clone your fork
|
||||
- Create a feature branch (`git checkout -b fix/my-change`)
|
||||
- Create a feature branch (`git checkout -b feat/my-change` or `git checkout -b fix/my-change`)
|
||||
- Make your changes and run `cargo fmt && cargo clippy && cargo test`
|
||||
- Open a PR against `master` using the PR template
|
||||
|
||||
@ -544,3 +559,4 @@ Recommended scope keys in commit titles:
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
# Contributing Guide Update
|
||||
|
||||
436
Cargo.lock
generated
436
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "zeroclaw"
|
||||
version = "0.1.7"
|
||||
version = "0.1.9"
|
||||
edition = "2021"
|
||||
authors = ["theonlyhennygod"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@ -85,6 +85,9 @@ rand = "0.10"
|
||||
# serde-big-array for wa-rs storage (large array serialization)
|
||||
serde-big-array = { version = "0.5", optional = true }
|
||||
|
||||
# Portable atomic fallbacks for 32-bit targets (no native 64-bit atomics)
|
||||
portable-atomic = { version = "1", optional = true }
|
||||
|
||||
# Fast mutexes that don't poison on panic
|
||||
parking_lot = "0.12"
|
||||
|
||||
@ -117,7 +120,7 @@ which = "8.0"
|
||||
# WebSocket client channels (Discord/Lark/DingTalk/Nostr)
|
||||
tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink"] }
|
||||
nostr-sdk = { version = "0.44", default-features = false, features = ["nip04", "nip59"] }
|
||||
nostr-sdk = { version = "0.44", default-features = false, features = ["nip04", "nip59"], optional = true }
|
||||
regex = "1.10"
|
||||
hostname = "0.4.2"
|
||||
rustls = "0.23"
|
||||
@ -184,10 +187,12 @@ landlock = { version = "0.4", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
default = ["channel-nostr"]
|
||||
channel-nostr = ["dep:nostr-sdk"]
|
||||
hardware = ["nusb", "tokio-serial"]
|
||||
channel-matrix = ["dep:matrix-sdk"]
|
||||
channel-lark = ["dep:prost"]
|
||||
channel-feishu = ["channel-lark"] # Alias for Feishu users (Lark and Feishu are the same platform)
|
||||
memory-postgres = ["dep:postgres"]
|
||||
observability-otel = ["dep:opentelemetry", "dep:opentelemetry_sdk", "dep:opentelemetry-otlp"]
|
||||
peripheral-rpi = ["rppal"]
|
||||
@ -220,6 +225,11 @@ inherits = "release"
|
||||
codegen-units = 8 # Parallel codegen for faster builds on powerful machines (16GB+ RAM recommended)
|
||||
# Use: cargo build --profile release-fast
|
||||
|
||||
[profile.ci]
|
||||
inherits = "release"
|
||||
lto = "thin" # Much faster than fat LTO; still catches release-mode issues
|
||||
codegen-units = 16 # Full parallelism for CI runners
|
||||
|
||||
[profile.dist]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────
|
||||
FROM rust:1.94-slim@sha256:d6782f2b326a10eaf593eb90cafc34a03a287b4a25fe4d0c693c90304b06f6d7 AS builder
|
||||
FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@ -90,6 +90,8 @@ COPY dev/config.template.toml /zeroclaw-data/.zeroclaw/config.toml
|
||||
RUN chown 65534:65534 /zeroclaw-data/.zeroclaw/config.toml
|
||||
|
||||
# Environment setup
|
||||
# Ensure UTF-8 locale so CJK / multibyte input is handled correctly
|
||||
ENV LANG=C.UTF-8
|
||||
# Use consistent workspace path
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
@ -114,6 +116,8 @@ COPY --from=builder /app/zeroclaw /usr/local/bin/zeroclaw
|
||||
COPY --from=builder /zeroclaw-data /zeroclaw-data
|
||||
|
||||
# Environment setup
|
||||
# Ensure UTF-8 locale so CJK / multibyte input is handled correctly
|
||||
ENV LANG=C.UTF-8
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
# Default provider and model are set in config.toml, not here,
|
||||
|
||||
@ -221,7 +221,7 @@ ls -lh target/release/zeroclaw
|
||||
يقوم نص `bootstrap.sh` بتثبيت Rust ونسخ ZeroClaw وتجميعه وإعداد بيئة التطوير الأولية الخاصة بك:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
سيقوم هذا بـ:
|
||||
|
||||
@ -221,7 +221,7 @@ Ukázková vzorka (macOS arm64, měřeno 18. února 2026):
|
||||
Skript `bootstrap.sh` nainstaluje Rust, naklonuje ZeroClaw, zkompiluje ho a nastaví vaše počáteční vývojové prostředí:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Toto:
|
||||
|
||||
@ -225,7 +225,7 @@ Beispielstichprobe (macOS arm64, gemessen am 18. Februar 2026):
|
||||
Das `bootstrap.sh`-Skript installiert Rust, klont ZeroClaw, kompiliert es und richtet deine anfängliche Entwicklungsumgebung ein:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Dies wird:
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
<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://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>
|
||||
</p>
|
||||
|
||||
|
||||
@ -221,7 +221,7 @@ Ejemplo de muestra (macOS arm64, medido el 18 de febrero de 2026):
|
||||
El script `bootstrap.sh` instala Rust, clona ZeroClaw, lo compila, y configura tu entorno de desarrollo inicial:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Esto:
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
<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://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 : Officiel" /></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.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>
|
||||
@ -94,7 +91,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), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (groupe)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), et [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 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/zeroclaw), 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
|
||||
@ -222,7 +219,7 @@ Exemple d'échantillon (macOS arm64, mesuré le 18 février 2026) :
|
||||
Le script `install.sh` installe Rust, clone ZeroClaw, le compile, et configure votre environnement de développement initial :
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
Ceci va :
|
||||
|
||||
@ -221,7 +221,7 @@ Esempio di campione (macOS arm64, misurato il 18 febbraio 2026):
|
||||
Lo script `bootstrap.sh` installa Rust, clona ZeroClaw, lo compila, e configura il tuo ambiente di sviluppo iniziale:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Questo:
|
||||
|
||||
@ -13,9 +13,6 @@
|
||||
<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://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.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>
|
||||
|
||||
@ -221,7 +221,7 @@ ls -lh target/release/zeroclaw
|
||||
`bootstrap.sh` 스크립트는 Rust를 설치하고, ZeroClaw를 클론하고, 컴파일하고, 초기 개발 환경을 설정합니다:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
이 작업은 다음을 수행합니다:
|
||||
|
||||
10
README.md
10
README.md
@ -14,9 +14,6 @@
|
||||
<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://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.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>
|
||||
@ -94,7 +91,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), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (Group)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), and [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) 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/zeroclaw), 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
|
||||
@ -211,7 +208,7 @@ Example sample (macOS arm64, measured on February 18, 2026):
|
||||
Or skip the steps above and install everything (system deps, Rust, ZeroClaw) in a single command:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
#### Compilation resource requirements
|
||||
@ -284,7 +281,7 @@ ZEROCLAW_CONTAINER_CLI=podman ./install.sh --docker
|
||||
Remote one-liner (review first in security-sensitive environments):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
Details: [`docs/setup-guides/one-click-bootstrap.md`](docs/setup-guides/one-click-bootstrap.md) (toolchain mode may request `sudo` for system packages).
|
||||
@ -1205,3 +1202,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) and [CLA.md](docs/contributing/cla.md). I
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
# Features Documentation
|
||||
|
||||
@ -221,7 +221,7 @@ Voorbeeld monster (macOS arm64, gemeten op 18 februari 2026):
|
||||
Het `bootstrap.sh` script installeert Rust, kloont ZeroClaw, compileert het, en stelt je initiële ontwikkelomgeving in:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Dit zal:
|
||||
|
||||
@ -221,7 +221,7 @@ Przykładowa próbka (macOS arm64, zmierzone 18 lutego 2026):
|
||||
Skrypt `bootstrap.sh` instaluje Rust, klonuje ZeroClaw, kompiluje go i konfiguruje twoje początkowe środowisko deweloperskie:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
To:
|
||||
|
||||
@ -221,7 +221,7 @@ Exemplo de amostra (macOS arm64, medido em 18 de fevereiro de 2026):
|
||||
O script `bootstrap.sh` instala Rust, clona ZeroClaw, compila, e configura seu ambiente de desenvolvimento inicial:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Isso vai:
|
||||
|
||||
@ -13,9 +13,6 @@
|
||||
<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://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.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>
|
||||
|
||||
@ -221,7 +221,7 @@ Halimbawa ng sample (macOS arm64, nasukat noong Pebrero 18, 2026):
|
||||
Ang `bootstrap.sh` script ay nag-i-install ng Rust, nagi-clone ng ZeroClaw, nagi-compile, at nagse-set up ng iyong paunang development environment:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Ito ay:
|
||||
|
||||
@ -221,7 +221,7 @@ ls -lh target/release/zeroclaw
|
||||
`bootstrap.sh` betiği Rust'u yükler, ZeroClaw'ı klonlar, derler ve ilk geliştirme ortamınızı ayarlar:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/bootstrap.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/bootstrap.sh | bash
|
||||
```
|
||||
|
||||
Bu işlem:
|
||||
|
||||
@ -14,9 +14,6 @@
|
||||
<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://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.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>
|
||||
@ -94,7 +91,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), [Telegram (@zeroclawlabs)](https://t.me/zeroclawlabs), [Facebook (nhóm)](https://www.facebook.com/groups/zeroclaw), [Reddit (r/zeroclawlabs)](https://www.reddit.com/r/zeroclawlabs/), và [Xiaohongshu](https://www.xiaohongshu.com/user/profile/67cbfc43000000000d008307?xsec_token=AB73VnYnGNx5y36EtnnZfGmAmS-6Wzv8WMuGpfwfkg6Yc%3D&xsec_source=pc_search) để 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/zeroclaw), 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
|
||||
@ -205,7 +202,7 @@ Ví dụ mẫu (macOS arm64, đo ngày 18 tháng 2 năm 2026):
|
||||
Hoặc bỏ qua các bước trên, cài hết mọi thứ (system deps, Rust, ZeroClaw) chỉ bằng một lệnh:
|
||||
|
||||
```bash
|
||||
curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -LsSf https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
#### Yêu cầu tài nguyên biên dịch
|
||||
@ -278,7 +275,7 @@ ZEROCLAW_CONTAINER_CLI=podman ./install.sh --docker
|
||||
Cài từ xa bằng một lệnh (nên xem trước nếu môi trường nhạy cảm về bảo mật):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
Chi tiết: [`docs/setup-guides/one-click-bootstrap.md`](docs/setup-guides/one-click-bootstrap.md) (chế độ toolchain có thể yêu cầu `sudo` cho các gói hệ thống).
|
||||
|
||||
@ -13,9 +13,6 @@
|
||||
<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://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.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>
|
||||
|
||||
@ -13,7 +13,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||
- `.github/workflows/ci-run.yml` (`CI`)
|
||||
- Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines)
|
||||
- Additional behavior: for Rust-impacting PRs and pushes, `CI Required Gate` requires `lint` + `test` + `build` (no PR build-only bypass)
|
||||
- Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,simianastronaut7`)
|
||||
- Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
|
||||
- Additional behavior: lint gates run before `test`/`build`; when lint/docs gates fail on PRs, CI posts an actionable feedback comment with failing gate names and local fix commands
|
||||
- Merge gate: `CI Required Gate`
|
||||
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
|
||||
|
||||
@ -13,7 +13,7 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
|
||||
- `.github/workflows/ci-run.yml` (`CI`)
|
||||
- Mục đích: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate trên các dòng Rust thay đổi, `test`, kiểm tra smoke release build) + kiểm tra chất lượng tài liệu khi tài liệu thay đổi (`markdownlint` chỉ chặn các vấn đề trên dòng thay đổi; link check chỉ quét các link mới được thêm trên dòng thay đổi)
|
||||
- Hành vi bổ sung: đối với PR và push ảnh hưởng Rust, `CI Required Gate` yêu cầu `lint` + `test` + `build` (không có shortcut chỉ build trên PR)
|
||||
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,simianastronaut7`)
|
||||
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
|
||||
- Hành vi bổ sung: lint gate chạy trước `test`/`build`; khi lint/docs gate thất bại trên PR, CI đăng comment phản hồi hành động được với tên gate thất bại và các lệnh sửa cục bộ
|
||||
- Merge gate: `CI Required Gate`
|
||||
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
|
||||
|
||||
@ -70,6 +70,7 @@ Lưu ý cho người dùng container:
|
||||
| `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 |
|
||||
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
|
||||
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
@ -77,6 +78,7 @@ Lưu ý:
|
||||
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
|
||||
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
|
||||
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
|
||||
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
|
||||
|
||||
## `[agents.<name>]`
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ Lưu ý:
|
||||
## Cách B: Lệnh từ xa một dòng
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
Với môi trường yêu cầu bảo mật cao, nên dùng Cách A để kiểm tra script trước khi chạy.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Tài liệu này liệt kê các provider ID, alias và biến môi trường chứa thông tin xác thực.
|
||||
|
||||
Cập nhật lần cuối: **2026-02-19**.
|
||||
Cập nhật lần cuối: **2026-03-10**.
|
||||
|
||||
## Cách liệt kê các Provider
|
||||
|
||||
@ -36,6 +36,7 @@ Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi pro
|
||||
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | Không | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
|
||||
| `synthetic` | — | Không | `SYNTHETIC_API_KEY` |
|
||||
| `opencode` | `opencode-zen` | Không | `OPENCODE_API_KEY` |
|
||||
| `opencode-go` | — | Không | `OPENCODE_GO_API_KEY` |
|
||||
| `zai` | `z.ai` | Không | `ZAI_API_KEY` |
|
||||
| `glm` | `zhipu` | Không | `GLM_API_KEY` |
|
||||
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | Không | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
|
||||
|
||||
@ -212,7 +212,7 @@ journalctl --user -u zeroclaw.service -f
|
||||
## URL cài đặt
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
## Vẫn chưa giải quyết được?
|
||||
|
||||
@ -19,7 +19,7 @@ Collected via GitHub CLI against `zeroclaw-labs/zeroclaw`:
|
||||
- Open Issues: **24**
|
||||
- Stars: **11,220**
|
||||
- Forks: **1,123**
|
||||
- Default branch: `main`
|
||||
- Default branch: `master`
|
||||
- License metadata on GitHub API: `Other` (not MIT-detected)
|
||||
|
||||
## PR Label Pressure (Open PRs)
|
||||
|
||||
@ -218,7 +218,7 @@ journalctl --user -u zeroclaw.service -f
|
||||
## Installer URL
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
## Still Stuck?
|
||||
|
||||
@ -81,6 +81,7 @@ Operational note for container users:
|
||||
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
|
||||
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
|
||||
| `tool_dispatcher` | `auto` | Tool dispatch strategy |
|
||||
| `tool_call_dedup_exempt` | `[]` | Tool names exempt from within-turn duplicate-call suppression |
|
||||
|
||||
Notes:
|
||||
|
||||
@ -88,6 +89,7 @@ Notes:
|
||||
- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations (<value>)`.
|
||||
- In CLI, gateway, and channel tool loops, multiple independent tool calls are executed concurrently by default when the pending calls do not require approval gating; result order remains stable.
|
||||
- `parallel_tools` applies to the `Agent::turn()` API surface. It does not gate the runtime loop used by CLI, gateway, or channel handlers.
|
||||
- `tool_call_dedup_exempt` accepts an array of exact tool names. Tools listed here are allowed to be called multiple times with identical arguments in the same turn, bypassing the dedup check. Example: `tool_call_dedup_exempt = ["browser"]`.
|
||||
|
||||
## `[security.otp]`
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ credential is not reused for fallback providers.
|
||||
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | No | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
|
||||
| `synthetic` | — | No | `SYNTHETIC_API_KEY` |
|
||||
| `opencode` | `opencode-zen` | No | `OPENCODE_API_KEY` |
|
||||
| `opencode-go` | — | No | `OPENCODE_GO_API_KEY` |
|
||||
| `zai` | `z.ai` | No | `ZAI_API_KEY` |
|
||||
| `glm` | `zhipu` | No | `GLM_API_KEY` |
|
||||
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | No | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
|
||||
|
||||
@ -39,6 +39,7 @@ Last verified: **February 21, 2026**.
|
||||
- `zeroclaw onboard --api-key <KEY> --provider <ID> --memory <sqlite|lucid|markdown|none>`
|
||||
- `zeroclaw onboard --api-key <KEY> --provider <ID> --model <MODEL_ID> --memory <sqlite|lucid|markdown|none>`
|
||||
- `zeroclaw onboard --api-key <KEY> --provider <ID> --model <MODEL_ID> --memory <sqlite|lucid|markdown|none> --force`
|
||||
- `zeroclaw onboard --reinit --interactive`
|
||||
|
||||
`onboard` safety behavior:
|
||||
|
||||
@ -47,6 +48,7 @@ Last verified: **February 21, 2026**.
|
||||
- Provider-only update (update provider/model/API key while preserving existing channels, tunnel, memory, hooks, and other settings)
|
||||
- In non-interactive environments, existing `config.toml` causes a safe refusal unless `--force` is passed.
|
||||
- Use `zeroclaw onboard --channels-only` when you only need to rotate channel tokens/allowlists.
|
||||
- Use `zeroclaw onboard --reinit --interactive` to start fresh. This backs up your existing config directory with a timestamp suffix and creates a new configuration from scratch. Requires `--interactive`.
|
||||
|
||||
### `agent`
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ Notes:
|
||||
## Option B: Remote one-liner
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
For high-security environments, prefer Option A so you can review the script before execution.
|
||||
|
||||
@ -13,7 +13,7 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
|
||||
- `.github/workflows/ci-run.yml` (`CI`)
|
||||
- Mục đích: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate trên các dòng Rust thay đổi, `test`, kiểm tra smoke release build) + kiểm tra chất lượng tài liệu khi tài liệu thay đổi (`markdownlint` chỉ chặn các vấn đề trên dòng thay đổi; link check chỉ quét các link mới được thêm trên dòng thay đổi)
|
||||
- Hành vi bổ sung: đối với PR và push ảnh hưởng Rust, `CI Required Gate` yêu cầu `lint` + `test` + `build` (không có shortcut chỉ build trên PR)
|
||||
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,simianastronaut7`)
|
||||
- Hành vi bổ sung: các PR thay đổi `.github/workflows/**` yêu cầu ít nhất một review phê duyệt từ login trong `WORKFLOW_OWNER_LOGINS` (fallback biến repository: `theonlyhennygod,JordanTheJet,SimianAstronaut7`)
|
||||
- Hành vi bổ sung: lint gate chạy trước `test`/`build`; khi lint/docs gate thất bại trên PR, CI đăng comment phản hồi hành động được với tên gate thất bại và các lệnh sửa cục bộ
|
||||
- Merge gate: `CI Required Gate`
|
||||
- `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`)
|
||||
|
||||
@ -70,6 +70,7 @@ Lưu ý cho người dùng container:
|
||||
| `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 |
|
||||
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
|
||||
| `tool_call_dedup_exempt` | `[]` | Tên tool được miễn kiểm tra trùng lặp trong cùng một lượt |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
@ -77,6 +78,7 @@ Lưu ý:
|
||||
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations (<value>)`.
|
||||
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
|
||||
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
|
||||
- `tool_call_dedup_exempt` nhận mảng tên tool chính xác. Các tool trong danh sách được phép gọi nhiều lần với cùng tham số trong một lượt. Ví dụ: `tool_call_dedup_exempt = ["browser"]`.
|
||||
|
||||
## `[agents.<name>]`
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ Lưu ý:
|
||||
## Cách B: Lệnh từ xa một dòng
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
Với môi trường yêu cầu bảo mật cao, nên dùng Cách A để kiểm tra script trước khi chạy.
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Tài liệu này liệt kê các provider ID, alias và biến môi trường chứa thông tin xác thực.
|
||||
|
||||
Cập nhật lần cuối: **2026-02-19**.
|
||||
Cập nhật lần cuối: **2026-03-10**.
|
||||
|
||||
## Cách liệt kê các Provider
|
||||
|
||||
@ -36,6 +36,7 @@ Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi pro
|
||||
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | Không | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
|
||||
| `synthetic` | — | Không | `SYNTHETIC_API_KEY` |
|
||||
| `opencode` | `opencode-zen` | Không | `OPENCODE_API_KEY` |
|
||||
| `opencode-go` | — | Không | `OPENCODE_GO_API_KEY` |
|
||||
| `zai` | `z.ai` | Không | `ZAI_API_KEY` |
|
||||
| `glm` | `zhipu` | Không | `GLM_API_KEY` |
|
||||
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | Không | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
|
||||
|
||||
@ -212,7 +212,7 @@ journalctl --user -u zeroclaw.service -f
|
||||
## URL cài đặt
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
## Vẫn chưa giải quyết được?
|
||||
|
||||
@ -101,7 +101,7 @@ Examples:
|
||||
./install.sh --docker
|
||||
|
||||
# Remote one-liner
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
|
||||
Environment:
|
||||
ZEROCLAW_CONTAINER_CLI Container CLI command (default: docker; auto-fallback: podman)
|
||||
@ -751,7 +751,7 @@ MSG
|
||||
fi
|
||||
|
||||
"$CONTAINER_CLI" run --rm -it \
|
||||
"${container_run_namespace_args[@]}" \
|
||||
"${container_run_namespace_args[@]+"${container_run_namespace_args[@]}"}" \
|
||||
"${container_run_user_args[@]}" \
|
||||
-e HOME=/zeroclaw-data \
|
||||
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
|
||||
|
||||
@ -41,9 +41,9 @@ def normalize_docs_files(raw: str) -> list[str]:
|
||||
def infer_base_sha(provided: str) -> str:
|
||||
if commit_exists(provided):
|
||||
return provided
|
||||
if run_git(["rev-parse", "--verify", "origin/main"]).returncode != 0:
|
||||
if run_git(["rev-parse", "--verify", "origin/master"]).returncode != 0:
|
||||
return ""
|
||||
proc = run_git(["merge-base", "origin/main", "HEAD"])
|
||||
proc = run_git(["merge-base", "origin/master", "HEAD"])
|
||||
candidate = proc.stdout.strip()
|
||||
return candidate if commit_exists(candidate) else ""
|
||||
|
||||
|
||||
@ -5,8 +5,8 @@ set -euo pipefail
|
||||
BASE_SHA="${BASE_SHA:-}"
|
||||
DOCS_FILES_RAW="${DOCS_FILES:-}"
|
||||
|
||||
if [ -z "$BASE_SHA" ] && git rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||
BASE_SHA="$(git merge-base origin/main HEAD)"
|
||||
if [ -z "$BASE_SHA" ] && git rev-parse --verify origin/master >/dev/null 2>&1; then
|
||||
BASE_SHA="$(git merge-base origin/master HEAD)"
|
||||
fi
|
||||
|
||||
if [ -z "$DOCS_FILES_RAW" ] && [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
||||
|
||||
@ -5,8 +5,8 @@ set -euo pipefail
|
||||
BASE_SHA="${BASE_SHA:-}"
|
||||
RUST_FILES_RAW="${RUST_FILES:-}"
|
||||
|
||||
if [ -z "$BASE_SHA" ] && git rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||
BASE_SHA="$(git merge-base origin/main HEAD)"
|
||||
if [ -z "$BASE_SHA" ] && git rev-parse --verify origin/master >/dev/null 2>&1; then
|
||||
BASE_SHA="$(git merge-base origin/master HEAD)"
|
||||
fi
|
||||
|
||||
if [ -z "$BASE_SHA" ] && git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
@ -15,7 +15,7 @@ fi
|
||||
|
||||
if [ -z "$BASE_SHA" ] || ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
|
||||
echo "BASE_SHA is missing or invalid for strict delta gate."
|
||||
echo "Set BASE_SHA explicitly or ensure origin/main is available."
|
||||
echo "Set BASE_SHA explicitly or ensure origin/master is available."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ Create an annotated release tag from the current checkout.
|
||||
Requirements:
|
||||
- tag must match vX.Y.Z (optional suffix like -rc.1)
|
||||
- working tree must be clean
|
||||
- HEAD must match origin/main
|
||||
- HEAD must match origin/master
|
||||
- tag must not already exist locally or on origin
|
||||
|
||||
Options:
|
||||
@ -49,14 +49,14 @@ if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Fetching origin/main and tags..."
|
||||
git fetch --quiet origin main --tags
|
||||
echo "Fetching origin/master and tags..."
|
||||
git fetch --quiet origin master --tags
|
||||
|
||||
HEAD_SHA="$(git rev-parse HEAD)"
|
||||
MAIN_SHA="$(git rev-parse origin/main)"
|
||||
if [[ "$HEAD_SHA" != "$MAIN_SHA" ]]; then
|
||||
echo "error: HEAD ($HEAD_SHA) is not origin/main ($MAIN_SHA)." >&2
|
||||
echo "hint: checkout/update main before cutting a release tag." >&2
|
||||
MASTER_SHA="$(git rev-parse origin/master)"
|
||||
if [[ "$HEAD_SHA" != "$MASTER_SHA" ]]; then
|
||||
echo "error: HEAD ($HEAD_SHA) is not origin/master ($MASTER_SHA)." >&2
|
||||
echo "hint: checkout/update master before cutting a release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@ -31,9 +31,12 @@ pub struct XmlToolDispatcher;
|
||||
|
||||
impl XmlToolDispatcher {
|
||||
fn parse_xml_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
|
||||
// Strip `<think>...</think>` blocks before parsing tool calls.
|
||||
// Qwen and other reasoning models may embed chain-of-thought inline.
|
||||
let cleaned = Self::strip_think_tags(response);
|
||||
let mut text_parts = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
let mut remaining = response;
|
||||
let mut remaining = cleaned.as_str();
|
||||
|
||||
while let Some(start) = remaining.find("<tool_call>") {
|
||||
let before = &remaining[..start];
|
||||
@ -81,6 +84,26 @@ impl XmlToolDispatcher {
|
||||
(text_parts.join("\n"), calls)
|
||||
}
|
||||
|
||||
/// Remove `<think>...</think>` blocks from model output.
|
||||
fn strip_think_tags(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
loop {
|
||||
if let Some(start) = rest.find("<think>") {
|
||||
result.push_str(&rest[..start]);
|
||||
if let Some(end) = rest[start..].find("</think>") {
|
||||
rest = &rest[start + end + "</think>".len()..];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
result.push_str(rest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn tool_specs(tools: &[Box<dyn Tool>]) -> Vec<ToolSpec> {
|
||||
tools.iter().map(|tool| tool.spec()).collect()
|
||||
}
|
||||
@ -259,6 +282,40 @@ mod tests {
|
||||
assert_eq!(calls[0].name, "shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_dispatcher_strips_think_before_tool_call() {
|
||||
let response = ChatResponse {
|
||||
text: Some(
|
||||
"<think>I should list files</think>\n<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call>"
|
||||
.into(),
|
||||
),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
let (text, calls) = dispatcher.parse_response(&response);
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "shell");
|
||||
assert!(
|
||||
!text.contains("<think>"),
|
||||
"think tags should be stripped from text"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_dispatcher_think_only_returns_no_calls() {
|
||||
let response = ChatResponse {
|
||||
text: Some("<think>Just thinking</think>".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
let (_, calls) = dispatcher.parse_response(&response);
|
||||
assert!(calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_dispatcher_roundtrip() {
|
||||
let response = ChatResponse {
|
||||
|
||||
@ -63,8 +63,17 @@ pub(crate) fn scrub_credentials(input: &str) -> String {
|
||||
.map(|m| m.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Preserve first 4 chars for context, then redact
|
||||
let prefix = if val.len() > 4 { &val[..4] } else { "" };
|
||||
// Preserve first 4 chars for context, then redact.
|
||||
// Use char_indices to find the byte offset of the 4th character
|
||||
// so we never slice in the middle of a multi-byte UTF-8 sequence.
|
||||
let prefix = if val.len() > 4 {
|
||||
val.char_indices()
|
||||
.nth(4)
|
||||
.map(|(byte_idx, _)| &val[..byte_idx])
|
||||
.unwrap_or(val)
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
if full_match.contains(':') {
|
||||
if full_match.contains('"') {
|
||||
@ -1312,6 +1321,13 @@ fn parse_glm_shortened_body(body: &str) -> Option<ParsedToolCall> {
|
||||
///
|
||||
/// Also supports JSON with `tool_calls` array from OpenAI-format responses.
|
||||
fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
|
||||
// Strip `<think>...</think>` blocks before parsing. Qwen and other
|
||||
// reasoning models embed chain-of-thought inline in the response text;
|
||||
// these tags can interfere with `<tool_call>` extraction and must be
|
||||
// removed first.
|
||||
let cleaned = strip_think_tags(response);
|
||||
let response = cleaned.as_str();
|
||||
|
||||
let mut text_parts = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
let mut remaining = response;
|
||||
@ -1694,6 +1710,30 @@ fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>) {
|
||||
(text_parts.join("\n"), calls)
|
||||
}
|
||||
|
||||
/// Remove `<think>...</think>` blocks from model output.
|
||||
/// Qwen and other reasoning models embed chain-of-thought inline in the
|
||||
/// response text using `<think>` tags. These must be removed before parsing
|
||||
/// tool-call tags or displaying output.
|
||||
fn strip_think_tags(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
loop {
|
||||
if let Some(start) = rest.find("<think>") {
|
||||
result.push_str(&rest[..start]);
|
||||
if let Some(end) = rest[start..].find("</think>") {
|
||||
rest = &rest[start + end + "</think>".len()..];
|
||||
} else {
|
||||
// Unclosed tag: drop the rest to avoid leaking partial reasoning.
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
result.push_str(rest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
/// Strip prompt-guided tool artifacts from visible output while preserving
|
||||
/// raw model text in history for future turns.
|
||||
fn strip_tool_result_blocks(text: &str) -> String {
|
||||
@ -1701,6 +1741,8 @@ fn strip_tool_result_blocks(text: &str) -> String {
|
||||
LazyLock::new(|| Regex::new(r"(?s)<tool_result[^>]*>.*?</tool_result>").unwrap());
|
||||
static THINKING_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?s)<thinking>.*?</thinking>").unwrap());
|
||||
static THINK_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?s)<think>.*?</think>").unwrap());
|
||||
static TOOL_RESULTS_PREFIX_RE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?m)^\[Tool results\]\s*\n?").unwrap());
|
||||
static EXCESS_BLANK_LINES_RE: LazyLock<Regex> =
|
||||
@ -1708,6 +1750,7 @@ fn strip_tool_result_blocks(text: &str) -> String {
|
||||
|
||||
let result = TOOL_RESULT_RE.replace_all(text, "");
|
||||
let result = THINKING_RE.replace_all(&result, "");
|
||||
let result = THINK_RE.replace_all(&result, "");
|
||||
let result = TOOL_RESULTS_PREFIX_RE.replace_all(&result, "");
|
||||
let result = EXCESS_BLANK_LINES_RE.replace_all(result.trim(), "\n\n");
|
||||
|
||||
@ -1856,6 +1899,18 @@ fn build_assistant_history_with_tool_calls(text: &str, tool_calls: &[ToolCall])
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
fn resolve_display_text(response_text: &str, parsed_text: &str, has_tool_calls: bool) -> String {
|
||||
if has_tool_calls {
|
||||
return parsed_text.to_string();
|
||||
}
|
||||
|
||||
if parsed_text.is_empty() {
|
||||
response_text.to_string()
|
||||
} else {
|
||||
parsed_text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ParsedToolCall {
|
||||
name: String,
|
||||
@ -1911,6 +1966,7 @@ pub(crate) async fn agent_turn(
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
}
|
||||
@ -1922,8 +1978,17 @@ async fn execute_one_tool(
|
||||
observer: &dyn Observer,
|
||||
cancellation_token: Option<&CancellationToken>,
|
||||
) -> Result<ToolExecutionOutcome> {
|
||||
let args_summary = {
|
||||
let raw = call_arguments.to_string();
|
||||
if raw.len() > 300 {
|
||||
format!("{}…", &raw[..300])
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
};
|
||||
observer.record_event(&ObserverEvent::ToolCallStart {
|
||||
tool: call_name.to_string(),
|
||||
arguments: Some(args_summary),
|
||||
});
|
||||
let start = Instant::now();
|
||||
|
||||
@ -2101,6 +2166,7 @@ pub(crate) async fn run_tool_call_loop(
|
||||
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
|
||||
hooks: Option<&crate::hooks::HookRunner>,
|
||||
excluded_tools: &[String],
|
||||
dedup_exempt_tools: &[String],
|
||||
) -> Result<String> {
|
||||
let max_iterations = if max_tool_iterations == 0 {
|
||||
DEFAULT_MAX_TOOL_ITERATIONS
|
||||
@ -2335,11 +2401,8 @@ pub(crate) async fn run_tool_call_loop(
|
||||
}
|
||||
};
|
||||
|
||||
let display_text = if parsed_text.is_empty() {
|
||||
response_text.clone()
|
||||
} else {
|
||||
parsed_text
|
||||
};
|
||||
let display_text =
|
||||
resolve_display_text(&response_text, &parsed_text, !tool_calls.is_empty());
|
||||
let display_text = strip_tool_result_blocks(&display_text);
|
||||
|
||||
// ── Progress: LLM responded ─────────────────────────────
|
||||
@ -2513,7 +2576,8 @@ pub(crate) async fn run_tool_call_loop(
|
||||
}
|
||||
|
||||
let signature = tool_call_signature(&tool_name, &tool_args);
|
||||
if !seen_tool_signatures.insert(signature) {
|
||||
let dedup_exempt = dedup_exempt_tools.iter().any(|e| e == &tool_name);
|
||||
if !dedup_exempt && !seen_tool_signatures.insert(signature) {
|
||||
let duplicate = format!(
|
||||
"Skipped duplicate tool call '{tool_name}' with identical arguments in this turn."
|
||||
);
|
||||
@ -2827,6 +2891,7 @@ pub async fn run(
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
};
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
@ -3056,6 +3121,7 @@ pub async fn run(
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
)
|
||||
.await?;
|
||||
final_output = response.clone();
|
||||
@ -3073,8 +3139,11 @@ pub async fn run(
|
||||
print!("> ");
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut input = String::new();
|
||||
match std::io::stdin().read_line(&mut input) {
|
||||
// Read raw bytes to avoid UTF-8 validation errors when PTY
|
||||
// transport splits multi-byte characters at frame boundaries
|
||||
// (e.g. CJK input with spaces over kubectl exec / SSH).
|
||||
let mut raw = Vec::new();
|
||||
match std::io::BufRead::read_until(&mut std::io::stdin().lock(), b'\n', &mut raw) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
@ -3082,6 +3151,7 @@ pub async fn run(
|
||||
break;
|
||||
}
|
||||
}
|
||||
let input = String::from_utf8_lossy(&raw).into_owned();
|
||||
|
||||
let user_input = input.trim().to_string();
|
||||
if user_input.is_empty() {
|
||||
@ -3104,10 +3174,17 @@ pub async fn run(
|
||||
print!("Continue? [y/N] ");
|
||||
let _ = std::io::stdout().flush();
|
||||
|
||||
let mut confirm = String::new();
|
||||
if std::io::stdin().read_line(&mut confirm).is_err() {
|
||||
let mut confirm_raw = Vec::new();
|
||||
if std::io::BufRead::read_until(
|
||||
&mut std::io::stdin().lock(),
|
||||
b'\n',
|
||||
&mut confirm_raw,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let confirm = String::from_utf8_lossy(&confirm_raw);
|
||||
if !matches!(confirm.trim().to_lowercase().as_str(), "y" | "yes") {
|
||||
println!("Cancelled.\n");
|
||||
continue;
|
||||
@ -3178,6 +3255,7 @@ pub async fn run(
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&config.agent.tool_call_dedup_exempt,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@ -3285,6 +3363,7 @@ pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
};
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
provider_name,
|
||||
@ -3722,6 +3801,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect_err("provider without vision support should fail");
|
||||
@ -3768,6 +3848,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect_err("oversized payload must fail");
|
||||
@ -3808,6 +3889,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("valid multimodal payload should pass");
|
||||
@ -3934,6 +4016,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("parallel execution should complete");
|
||||
@ -4003,6 +4086,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("loop should finish after deduplicating repeated calls");
|
||||
@ -4022,6 +4106,142 @@ mod tests {
|
||||
assert!(tool_results.content.contains("Skipped duplicate tool call"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_dedup_exempt_allows_repeated_calls() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"count_tool","arguments":{"value":"A"}}
|
||||
</tool_call>
|
||||
<tool_call>
|
||||
{"name":"count_tool","arguments":{"value":"A"}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let invocations = Arc::new(AtomicUsize::new(0));
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(CountingTool::new(
|
||||
"count_tool",
|
||||
Arc::clone(&invocations),
|
||||
))];
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("run tool calls"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
let exempt = vec!["count_tool".to_string()];
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&exempt,
|
||||
)
|
||||
.await
|
||||
.expect("loop should finish with exempt tool executing twice");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
assert_eq!(
|
||||
invocations.load(Ordering::SeqCst),
|
||||
2,
|
||||
"exempt tool should execute both duplicate calls"
|
||||
);
|
||||
|
||||
let tool_results = history
|
||||
.iter()
|
||||
.find(|msg| msg.role == "user" && msg.content.starts_with("[Tool results]"))
|
||||
.expect("prompt-mode tool result payload should be present");
|
||||
assert!(
|
||||
!tool_results.content.contains("Skipped duplicate tool call"),
|
||||
"exempt tool calls should not be suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_dedup_exempt_only_affects_listed_tools() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"count_tool","arguments":{"value":"A"}}
|
||||
</tool_call>
|
||||
<tool_call>
|
||||
{"name":"count_tool","arguments":{"value":"A"}}
|
||||
</tool_call>
|
||||
<tool_call>
|
||||
{"name":"other_tool","arguments":{"value":"B"}}
|
||||
</tool_call>
|
||||
<tool_call>
|
||||
{"name":"other_tool","arguments":{"value":"B"}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let count_invocations = Arc::new(AtomicUsize::new(0));
|
||||
let other_invocations = Arc::new(AtomicUsize::new(0));
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![
|
||||
Box::new(CountingTool::new(
|
||||
"count_tool",
|
||||
Arc::clone(&count_invocations),
|
||||
)),
|
||||
Box::new(CountingTool::new(
|
||||
"other_tool",
|
||||
Arc::clone(&other_invocations),
|
||||
)),
|
||||
];
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("run tool calls"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
let exempt = vec!["count_tool".to_string()];
|
||||
|
||||
let _result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
None,
|
||||
"cli",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&exempt,
|
||||
)
|
||||
.await
|
||||
.expect("loop should complete");
|
||||
|
||||
assert_eq!(
|
||||
count_invocations.load(Ordering::SeqCst),
|
||||
2,
|
||||
"exempt tool should execute both calls"
|
||||
);
|
||||
assert_eq!(
|
||||
other_invocations.load(Ordering::SeqCst),
|
||||
1,
|
||||
"non-exempt tool should still be deduped"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_native_mode_preserves_fallback_tool_call_ids() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
@ -4059,6 +4279,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("native fallback id flow should complete");
|
||||
@ -4079,6 +4300,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_display_text_hides_raw_payload_for_tool_only_turns() {
|
||||
let display = resolve_display_text(
|
||||
"<tool_call>{\"name\":\"memory_store\"}</tool_call>",
|
||||
"",
|
||||
true,
|
||||
);
|
||||
assert!(display.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_display_text_keeps_plain_text_for_tool_turns() {
|
||||
let display = resolve_display_text(
|
||||
"<tool_call>{\"name\":\"shell\"}</tool_call>",
|
||||
"Let me check that.",
|
||||
true,
|
||||
);
|
||||
assert_eq!(display, "Let me check that.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_display_text_uses_response_text_for_final_turns() {
|
||||
let display = resolve_display_text("Final answer", "", false);
|
||||
assert_eq!(display, "Final answer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_extracts_single_call() {
|
||||
let response = r#"Let me check that.
|
||||
@ -4830,6 +5077,76 @@ Final answer."#;
|
||||
assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_tool_result_blocks_removes_think_tags() {
|
||||
let input = "<think>\nLet me reason...\n</think>\nHere is the answer.";
|
||||
assert_eq!(strip_tool_result_blocks(input), "Here is the answer.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_removes_single_block() {
|
||||
assert_eq!(strip_think_tags("<think>reasoning</think>Hello"), "Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_removes_multiple_blocks() {
|
||||
assert_eq!(strip_think_tags("<think>a</think>X<think>b</think>Y"), "XY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_handles_unclosed_block() {
|
||||
assert_eq!(strip_think_tags("visible<think>hidden"), "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_preserves_text_without_tags() {
|
||||
assert_eq!(strip_think_tags("plain text"), "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_strips_think_before_tool_call() {
|
||||
// Qwen regression: <think> tags before <tool_call> tags should be
|
||||
// stripped, allowing the tool call to be parsed correctly.
|
||||
let response = "<think>I need to list files to understand the project</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}\n</tool_call>";
|
||||
let (text, calls) = parse_tool_calls(response);
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"should parse tool call after stripping think tags"
|
||||
);
|
||||
assert_eq!(calls[0].name, "shell");
|
||||
assert_eq!(
|
||||
calls[0].arguments.get("command").unwrap().as_str().unwrap(),
|
||||
"ls"
|
||||
);
|
||||
assert!(text.is_empty(), "think content should not appear as text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_strips_think_only_returns_empty() {
|
||||
// When response is only <think> tags with no tool calls, should
|
||||
// return empty text and no calls.
|
||||
let response = "<think>Just thinking, no action needed</think>";
|
||||
let (text, calls) = parse_tool_calls(response);
|
||||
assert!(calls.is_empty());
|
||||
assert!(text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tool_calls_handles_qwen_think_with_multiple_tool_calls() {
|
||||
let response = "<think>I need to check two things</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"pwd\"}}\n</tool_call>";
|
||||
let (_, calls) = parse_tool_calls(response);
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(
|
||||
calls[0].arguments.get("command").unwrap().as_str().unwrap(),
|
||||
"date"
|
||||
);
|
||||
assert_eq!(
|
||||
calls[1].arguments.get("command").unwrap().as_str().unwrap(),
|
||||
"pwd"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_tool_result_blocks_preserves_clean_text() {
|
||||
let input = "Hello, this is a normal response.";
|
||||
@ -5320,6 +5637,20 @@ Let me check the result."#;
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_credentials_multibyte_chars_no_panic() {
|
||||
// Regression test for #3024: byte index 4 is not a char boundary
|
||||
// when the captured value contains multi-byte UTF-8 characters.
|
||||
// The regex only matches quoted values for non-ASCII content, since
|
||||
// capture group 4 is restricted to [a-zA-Z0-9_\-\.].
|
||||
let input = "password=\"\u{4f60}\u{7684}WiFi\u{5bc6}\u{7801}ab\"";
|
||||
let result = scrub_credentials(input);
|
||||
assert!(
|
||||
result.contains("[REDACTED]"),
|
||||
"multi-byte quoted value should be redacted without panic, got: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrub_credentials_short_values_not_redacted() {
|
||||
// Values shorter than 8 chars should not be redacted
|
||||
|
||||
@ -622,7 +622,18 @@ impl Channel for DiscordChannel {
|
||||
msg = read.next() => {
|
||||
let msg = match msg {
|
||||
Some(Ok(Message::Text(t))) => t,
|
||||
Some(Ok(Message::Ping(payload))) => {
|
||||
if write.send(Message::Pong(payload)).await.is_err() {
|
||||
tracing::warn!("Discord: pong send failed, reconnecting");
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!("Discord: websocket read error: {e}, reconnecting");
|
||||
break;
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
|
||||
@ -67,6 +67,9 @@ pub struct EmailConfig {
|
||||
/// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all)
|
||||
#[serde(default)]
|
||||
pub allowed_senders: Vec<String>,
|
||||
/// Default subject line for outgoing emails (default: "ZeroClaw Message")
|
||||
#[serde(default = "default_subject")]
|
||||
pub default_subject: String,
|
||||
}
|
||||
|
||||
impl crate::config::traits::ChannelConfig for EmailConfig {
|
||||
@ -93,6 +96,9 @@ fn default_idle_timeout() -> u64 {
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_subject() -> String {
|
||||
"ZeroClaw Message".into()
|
||||
}
|
||||
|
||||
impl Default for EmailConfig {
|
||||
fn default() -> Self {
|
||||
@ -108,6 +114,7 @@ impl Default for EmailConfig {
|
||||
from_address: String::new(),
|
||||
idle_timeout_secs: default_idle_timeout(),
|
||||
allowed_senders: Vec::new(),
|
||||
default_subject: default_subject(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -512,16 +519,17 @@ impl Channel for EmailChannel {
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||
// Use explicit subject if provided, otherwise fall back to legacy parsing or default
|
||||
let default_subject = self.config.default_subject.as_str();
|
||||
let (subject, body) = if let Some(ref subj) = message.subject {
|
||||
(subj.as_str(), message.content.as_str())
|
||||
} else if message.content.starts_with("Subject: ") {
|
||||
if let Some(pos) = message.content.find('\n') {
|
||||
(&message.content[9..pos], message.content[pos + 1..].trim())
|
||||
} else {
|
||||
("ZeroClaw Message", message.content.as_str())
|
||||
(default_subject, message.content.as_str())
|
||||
}
|
||||
} else {
|
||||
("ZeroClaw Message", message.content.as_str())
|
||||
(default_subject, message.content.as_str())
|
||||
};
|
||||
|
||||
let email = Message::builder()
|
||||
@ -635,10 +643,12 @@ mod tests {
|
||||
from_address: "bot@example.com".to_string(),
|
||||
idle_timeout_secs: 1200,
|
||||
allowed_senders: vec!["allowed@example.com".to_string()],
|
||||
default_subject: "Custom Subject".to_string(),
|
||||
};
|
||||
assert_eq!(config.imap_host, "imap.example.com");
|
||||
assert_eq!(config.imap_folder, "Archive");
|
||||
assert_eq!(config.idle_timeout_secs, 1200);
|
||||
assert_eq!(config.default_subject, "Custom Subject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -655,11 +665,13 @@ mod tests {
|
||||
from_address: "bot@test.com".to_string(),
|
||||
idle_timeout_secs: 1740,
|
||||
allowed_senders: vec!["*".to_string()],
|
||||
default_subject: "Test Subject".to_string(),
|
||||
};
|
||||
let cloned = config.clone();
|
||||
assert_eq!(cloned.imap_host, config.imap_host);
|
||||
assert_eq!(cloned.smtp_port, config.smtp_port);
|
||||
assert_eq!(cloned.allowed_senders, config.allowed_senders);
|
||||
assert_eq!(cloned.default_subject, config.default_subject);
|
||||
}
|
||||
|
||||
// EmailChannel tests
|
||||
@ -900,6 +912,7 @@ mod tests {
|
||||
from_address: "bot@example.com".to_string(),
|
||||
idle_timeout_secs: 1740,
|
||||
allowed_senders: vec!["allowed@example.com".to_string()],
|
||||
default_subject: "Serialization Test".to_string(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@ -908,6 +921,7 @@ mod tests {
|
||||
assert_eq!(deserialized.imap_host, config.imap_host);
|
||||
assert_eq!(deserialized.smtp_port, config.smtp_port);
|
||||
assert_eq!(deserialized.allowed_senders, config.allowed_senders);
|
||||
assert_eq!(deserialized.default_subject, config.default_subject);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -925,6 +939,7 @@ mod tests {
|
||||
assert_eq!(config.smtp_port, 465); // default
|
||||
assert!(config.smtp_tls); // default
|
||||
assert_eq!(config.idle_timeout_secs, 1740); // default
|
||||
assert_eq!(config.default_subject, "ZeroClaw Message"); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -296,8 +296,8 @@ pub struct LarkChannel {
|
||||
/// Bot open_id resolved at runtime via `/bot/v3/info`.
|
||||
resolved_bot_open_id: Arc<StdRwLock<Option<String>>>,
|
||||
mention_only: bool,
|
||||
/// When true, use Feishu (CN) endpoints; when false, use Lark (international).
|
||||
use_feishu: bool,
|
||||
/// Platform variant: Lark (international) or Feishu (CN).
|
||||
platform: LarkPlatform,
|
||||
/// How to receive events: WebSocket long-connection or HTTP webhook.
|
||||
receive_mode: crate::config::schema::LarkReceiveMode,
|
||||
/// Cached tenant access token
|
||||
@ -321,6 +321,7 @@ impl LarkChannel {
|
||||
verification_token,
|
||||
port,
|
||||
allowed_users,
|
||||
mention_only,
|
||||
LarkPlatform::Lark,
|
||||
)
|
||||
}
|
||||
@ -331,6 +332,7 @@ impl LarkChannel {
|
||||
verification_token: String,
|
||||
port: Option<u16>,
|
||||
allowed_users: Vec<String>,
|
||||
mention_only: bool,
|
||||
platform: LarkPlatform,
|
||||
) -> Self {
|
||||
Self {
|
||||
@ -341,7 +343,7 @@ impl LarkChannel {
|
||||
allowed_users,
|
||||
resolved_bot_open_id: Arc::new(StdRwLock::new(None)),
|
||||
mention_only,
|
||||
use_feishu: true,
|
||||
platform,
|
||||
receive_mode: crate::config::schema::LarkReceiveMode::default(),
|
||||
tenant_token: Arc::new(RwLock::new(None)),
|
||||
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
|
||||
@ -363,6 +365,39 @@ impl LarkChannel {
|
||||
config.port,
|
||||
config.allowed_users.clone(),
|
||||
config.mention_only,
|
||||
platform,
|
||||
);
|
||||
ch.receive_mode = config.receive_mode.clone();
|
||||
ch
|
||||
}
|
||||
|
||||
/// Build from `LarkConfig` forcing `LarkPlatform::Lark`, ignoring the
|
||||
/// legacy `use_feishu` flag. Used by the channel factory when the config
|
||||
/// section is explicitly `[channels_config.lark]`.
|
||||
pub fn from_lark_config(config: &crate::config::schema::LarkConfig) -> Self {
|
||||
let mut ch = Self::new_with_platform(
|
||||
config.app_id.clone(),
|
||||
config.app_secret.clone(),
|
||||
config.verification_token.clone().unwrap_or_default(),
|
||||
config.port,
|
||||
config.allowed_users.clone(),
|
||||
config.mention_only,
|
||||
LarkPlatform::Lark,
|
||||
);
|
||||
ch.receive_mode = config.receive_mode.clone();
|
||||
ch
|
||||
}
|
||||
|
||||
/// Build from `FeishuConfig` with `LarkPlatform::Feishu`.
|
||||
pub fn from_feishu_config(config: &crate::config::schema::FeishuConfig) -> Self {
|
||||
let mut ch = Self::new_with_platform(
|
||||
config.app_id.clone(),
|
||||
config.app_secret.clone(),
|
||||
config.verification_token.clone().unwrap_or_default(),
|
||||
config.port,
|
||||
config.allowed_users.clone(),
|
||||
false,
|
||||
LarkPlatform::Feishu,
|
||||
);
|
||||
ch.receive_mode = config.receive_mode.clone();
|
||||
ch
|
||||
@ -2078,6 +2113,7 @@ mod tests {
|
||||
encrypt_key: None,
|
||||
verification_token: Some("vtoken789".into()),
|
||||
allowed_users: vec!["*".into()],
|
||||
mention_only: false,
|
||||
use_feishu: true,
|
||||
receive_mode: LarkReceiveMode::Webhook,
|
||||
port: Some(9898),
|
||||
|
||||
@ -4,16 +4,21 @@ use matrix_sdk::{
|
||||
authentication::matrix::MatrixSession,
|
||||
config::SyncSettings,
|
||||
ruma::{
|
||||
events::reaction::ReactionEventContent,
|
||||
events::relation::{Annotation, InReplyTo, Thread},
|
||||
events::room::message::{
|
||||
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
|
||||
},
|
||||
OwnedRoomId, OwnedUserId,
|
||||
events::room::MediaSource,
|
||||
OwnedEventId, OwnedRoomId, OwnedUserId,
|
||||
},
|
||||
Client as MatrixSdkClient, LoopCtrl, Room, RoomState, SessionMeta, SessionTokens,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{mpsc, Mutex, OnceCell, RwLock};
|
||||
|
||||
@ -31,6 +36,8 @@ pub struct MatrixChannel {
|
||||
resolved_room_id_cache: Arc<RwLock<Option<String>>>,
|
||||
sdk_client: Arc<OnceCell<MatrixSdkClient>>,
|
||||
http_client: Client,
|
||||
reaction_events: Arc<RwLock<HashMap<String, String>>>,
|
||||
voice_mode: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MatrixChannel {
|
||||
@ -163,6 +170,8 @@ impl MatrixChannel {
|
||||
resolved_room_id_cache: Arc::new(RwLock::new(None)),
|
||||
sdk_client: Arc::new(OnceCell::new()),
|
||||
http_client: Client::new(),
|
||||
reaction_events: Arc::new(RwLock::new(HashMap::new())),
|
||||
voice_mode: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,7 +539,16 @@ impl Channel for MatrixChannel {
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
let client = self.matrix_client().await?;
|
||||
let target_room_id = self.target_room_id().await?;
|
||||
let target_room_id = if message.recipient.contains("||") {
|
||||
message
|
||||
.recipient
|
||||
.splitn(2, "||")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
} else {
|
||||
self.target_room_id().await?
|
||||
};
|
||||
let target_room: OwnedRoomId = target_room_id.parse()?;
|
||||
|
||||
let mut room = client.get_room(&target_room);
|
||||
@ -547,8 +565,94 @@ impl Channel for MatrixChannel {
|
||||
anyhow::bail!("Matrix room '{}' is not in joined state", target_room_id);
|
||||
}
|
||||
|
||||
room.send(RoomMessageEventContent::text_markdown(&message.content))
|
||||
.await?;
|
||||
let mut content = RoomMessageEventContent::text_markdown(&message.content);
|
||||
|
||||
if let Some(ref thread_ts) = message.thread_ts {
|
||||
if let Ok(thread_root) = thread_ts.parse::<OwnedEventId>() {
|
||||
content.relates_to = Some(Relation::Thread(Thread::plain(
|
||||
thread_root.clone(),
|
||||
thread_root,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
room.send(content).await?;
|
||||
|
||||
// Voice reply: generate TTS audio and send as m.audio when voice_mode is active
|
||||
if self.voice_mode.load(Ordering::Relaxed) {
|
||||
self.voice_mode.store(false, Ordering::Relaxed);
|
||||
tracing::info!("Voice mode active, generating TTS reply");
|
||||
let voice_work = std::path::PathBuf::from("/tmp/zeroclaw-voice");
|
||||
let _ = tokio::fs::create_dir_all(&voice_work).await;
|
||||
let mp3_path = voice_work.join("reply.mp3");
|
||||
|
||||
let tts_text = message
|
||||
.content
|
||||
.replace("**", "")
|
||||
.replace("*", "")
|
||||
.replace("`", "")
|
||||
.replace("# ", "");
|
||||
|
||||
let tts_ok = tokio::process::Command::new("edge-tts")
|
||||
.arg("--text")
|
||||
.arg(&tts_text)
|
||||
.arg("--write-media")
|
||||
.arg(&mp3_path)
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
|
||||
if tts_ok && mp3_path.exists() {
|
||||
if let Ok(audio_data) = tokio::fs::read(&mp3_path).await {
|
||||
let upload_url = format!(
|
||||
"{}/_matrix/media/v3/upload?filename=voice-reply.mp3",
|
||||
self.homeserver
|
||||
);
|
||||
if let Ok(resp) = self
|
||||
.http_client
|
||||
.post(&upload_url)
|
||||
.header("Authorization", self.auth_header_value())
|
||||
.header("Content-Type", "audio/mpeg")
|
||||
.body(audio_data)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
if resp.status().is_success() {
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().await {
|
||||
if let Some(content_uri) = body["content_uri"].as_str() {
|
||||
let encoded_room = Self::encode_path_segment(&target_room_id);
|
||||
let txn_id = format!(
|
||||
"voice_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
);
|
||||
let audio_msg = serde_json::json!({
|
||||
"msgtype": "m.audio",
|
||||
"body": "Voice reply",
|
||||
"url": content_uri,
|
||||
"info": { "mimetype": "audio/mpeg" }
|
||||
});
|
||||
let send_url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/send/m.room.message/{}",
|
||||
self.homeserver, encoded_room, txn_id
|
||||
);
|
||||
let _ = self
|
||||
.http_client
|
||||
.put(&send_url)
|
||||
.header("Authorization", self.auth_header_value())
|
||||
.json(&audio_msg)
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -593,6 +697,9 @@ impl Channel for MatrixChannel {
|
||||
let my_user_id_for_handler = my_user_id.clone();
|
||||
let allowed_users_for_handler = self.allowed_users.clone();
|
||||
let dedupe_for_handler = Arc::clone(&recent_event_cache);
|
||||
let homeserver_for_handler = self.homeserver.clone();
|
||||
let access_token_for_handler = self.access_token.clone();
|
||||
let voice_mode_for_handler = Arc::clone(&self.voice_mode);
|
||||
|
||||
client.add_event_handler(move |event: OriginalSyncRoomMessageEvent, room: Room| {
|
||||
let tx = tx_handler.clone();
|
||||
@ -600,9 +707,14 @@ impl Channel for MatrixChannel {
|
||||
let my_user_id = my_user_id_for_handler.clone();
|
||||
let allowed_users = allowed_users_for_handler.clone();
|
||||
let dedupe = Arc::clone(&dedupe_for_handler);
|
||||
let homeserver = homeserver_for_handler.clone();
|
||||
let access_token = access_token_for_handler.clone();
|
||||
let voice_mode = Arc::clone(&voice_mode_for_handler);
|
||||
|
||||
async move {
|
||||
if room.room_id().as_str() != target_room.as_str() {
|
||||
if false
|
||||
/* multi-room: room_id filter disabled */
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@ -615,12 +727,124 @@ impl Channel for MatrixChannel {
|
||||
return;
|
||||
}
|
||||
|
||||
let body = match &event.content.msgtype {
|
||||
MessageType::Text(content) => content.body.clone(),
|
||||
MessageType::Notice(content) => content.body.clone(),
|
||||
// Helper: extract mxc:// download URL and filename for media types
|
||||
let media_info = |source: &MediaSource, name: &str| -> Option<(String, String)> {
|
||||
match source {
|
||||
MediaSource::Plain(mxc) => {
|
||||
let rest = mxc.as_str().strip_prefix("mxc://")?;
|
||||
let url =
|
||||
format!("{}/_matrix/client/v1/media/download/{}", homeserver, rest);
|
||||
Some((url, name.to_string()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let (body, media_download) = match &event.content.msgtype {
|
||||
MessageType::Text(content) => (content.body.clone(), None),
|
||||
MessageType::Notice(content) => (content.body.clone(), None),
|
||||
MessageType::Image(content) => {
|
||||
let dl = media_info(&content.source, &content.body);
|
||||
(format!("[image: {}]", content.body), dl)
|
||||
}
|
||||
MessageType::File(content) => {
|
||||
let dl = media_info(&content.source, &content.body);
|
||||
(format!("[file: {}]", content.body), dl)
|
||||
}
|
||||
MessageType::Audio(content) => {
|
||||
let dl = media_info(&content.source, &content.body);
|
||||
(format!("[audio: {}]", content.body), dl)
|
||||
}
|
||||
MessageType::Video(content) => {
|
||||
let dl = media_info(&content.source, &content.body);
|
||||
(format!("[video: {}]", content.body), dl)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Download media to workspace if present
|
||||
let body = if let Some((url, filename)) = media_download {
|
||||
let workspace = std::path::PathBuf::from(
|
||||
std::env::var("ZEROCLAW_WORKSPACE")
|
||||
.unwrap_or_else(|_| "/tmp/zeroclaw-uploads".to_string()),
|
||||
);
|
||||
let _ = tokio::fs::create_dir_all(&workspace).await;
|
||||
let dest = workspace.join(&filename);
|
||||
let client = reqwest::Client::new();
|
||||
match client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
||||
Ok(bytes) => match tokio::fs::write(&dest, &bytes).await {
|
||||
Ok(_) => format!("{} — saved to {}", body, dest.display()),
|
||||
Err(_) => format!("{} — failed to write to disk", body),
|
||||
},
|
||||
Err(_) => format!("{} — download failed", body),
|
||||
},
|
||||
_ => format!("{} — download failed (auth error?)", body),
|
||||
}
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
// Voice transcription: if this was an audio message, transcribe it
|
||||
let body = if body.starts_with("[audio:") {
|
||||
if let Some(path_start) = body.find("saved to ") {
|
||||
let audio_path = body[path_start + 9..].to_string();
|
||||
let wav_path = format!("{}.16k.wav", audio_path);
|
||||
let convert_ok = tokio::process::Command::new("ffmpeg")
|
||||
.args([
|
||||
"-y",
|
||||
"-i",
|
||||
&audio_path,
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
"-f",
|
||||
"wav",
|
||||
&wav_path,
|
||||
])
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if convert_ok {
|
||||
let transcription = tokio::process::Command::new("whisper-cpp")
|
||||
.args([
|
||||
"-m",
|
||||
"/tmp/ggml-base.en.bin",
|
||||
"-f",
|
||||
&wav_path,
|
||||
"--no-timestamps",
|
||||
"-nt",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(text) = transcription {
|
||||
voice_mode.store(true, Ordering::Relaxed);
|
||||
format!("[Voice message]: {}", text)
|
||||
} else {
|
||||
body
|
||||
}
|
||||
} else {
|
||||
body
|
||||
}
|
||||
} else {
|
||||
body
|
||||
}
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
if !MatrixChannel::has_non_empty_body(&body) {
|
||||
return;
|
||||
}
|
||||
@ -634,17 +858,21 @@ impl Channel for MatrixChannel {
|
||||
}
|
||||
}
|
||||
|
||||
let thread_ts = match &event.content.relates_to {
|
||||
Some(Relation::Thread(thread)) => Some(thread.event_id.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let msg = ChannelMessage {
|
||||
id: event_id,
|
||||
sender: sender.clone(),
|
||||
reply_target: sender,
|
||||
reply_target: format!("{}||{}", sender, room.room_id()),
|
||||
content: body,
|
||||
channel: "matrix".to_string(),
|
||||
channel: format!("matrix:{}", room.room_id()),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
thread_ts,
|
||||
};
|
||||
|
||||
let _ = tx.send(msg).await;
|
||||
@ -684,6 +912,179 @@ impl Channel for MatrixChannel {
|
||||
|
||||
self.matrix_client().await.is_ok()
|
||||
}
|
||||
|
||||
async fn add_reaction(
|
||||
&self,
|
||||
_channel_id: &str,
|
||||
message_id: &str,
|
||||
emoji: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = self.matrix_client().await?;
|
||||
let target_room_id = self.target_room_id().await?;
|
||||
let target_room: OwnedRoomId = target_room_id.parse()?;
|
||||
|
||||
let room = client
|
||||
.get_room(&target_room)
|
||||
.ok_or_else(|| anyhow::anyhow!("Matrix room not found for reaction"))?;
|
||||
|
||||
let event_id: OwnedEventId = message_id
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid event ID for reaction: {}", message_id))?;
|
||||
|
||||
let reaction = ReactionEventContent::new(Annotation::new(event_id, emoji.to_string()));
|
||||
let response = room.send(reaction).await?;
|
||||
|
||||
let key = format!("{}:{}", message_id, emoji);
|
||||
self.reaction_events
|
||||
.write()
|
||||
.await
|
||||
.insert(key, response.event_id.to_string());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_reaction(
|
||||
&self,
|
||||
_channel_id: &str,
|
||||
message_id: &str,
|
||||
emoji: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let key = format!("{}:{}", message_id, emoji);
|
||||
let reaction_event_id = self.reaction_events.write().await.remove(&key);
|
||||
|
||||
if let Some(reaction_event_id) = reaction_event_id {
|
||||
let client = self.matrix_client().await?;
|
||||
let target_room_id = self.target_room_id().await?;
|
||||
let target_room: OwnedRoomId = target_room_id.parse()?;
|
||||
|
||||
let room = client
|
||||
.get_room(&target_room)
|
||||
.ok_or_else(|| anyhow::anyhow!("Matrix room not found for reaction removal"))?;
|
||||
|
||||
let event_id: OwnedEventId = reaction_event_id
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid reaction event ID: {}", reaction_event_id))?;
|
||||
|
||||
room.redact(&event_id, None, None).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pin_message(&self, _channel_id: &str, message_id: &str) -> anyhow::Result<()> {
|
||||
let room_id = self.target_room_id().await?;
|
||||
let encoded_room = Self::encode_path_segment(&room_id);
|
||||
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
|
||||
self.homeserver, encoded_room
|
||||
);
|
||||
let resp = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header_value())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut pinned: Vec<String> = if resp.status().is_success() {
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
body.get("pinned")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let msg_id = message_id.to_string();
|
||||
if pinned.contains(&msg_id) {
|
||||
return Ok(());
|
||||
}
|
||||
pinned.push(msg_id);
|
||||
|
||||
let put_url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
|
||||
self.homeserver, encoded_room
|
||||
);
|
||||
let body = serde_json::json!({ "pinned": pinned });
|
||||
let resp = self
|
||||
.http_client
|
||||
.put(&put_url)
|
||||
.header("Authorization", self.auth_header_value())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Matrix pin_message failed: {err}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unpin_message(&self, _channel_id: &str, message_id: &str) -> anyhow::Result<()> {
|
||||
let room_id = self.target_room_id().await?;
|
||||
let encoded_room = Self::encode_path_segment(&room_id);
|
||||
|
||||
let url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
|
||||
self.homeserver, encoded_room
|
||||
);
|
||||
let resp = self
|
||||
.http_client
|
||||
.get(&url)
|
||||
.header("Authorization", self.auth_header_value())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let body: serde_json::Value = resp.json().await?;
|
||||
let mut pinned: Vec<String> = body
|
||||
.get("pinned")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let msg_id = message_id.to_string();
|
||||
let original_len = pinned.len();
|
||||
pinned.retain(|id| id != &msg_id);
|
||||
|
||||
if pinned.len() == original_len {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let put_url = format!(
|
||||
"{}/_matrix/client/v3/rooms/{}/state/m.room.pinned_events",
|
||||
self.homeserver, encoded_room
|
||||
);
|
||||
let body = serde_json::json!({ "pinned": pinned });
|
||||
let resp = self
|
||||
.http_client
|
||||
.put(&put_url)
|
||||
.header("Authorization", self.auth_header_value())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Matrix unpin_message failed: {err}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -28,6 +28,7 @@ pub mod linq;
|
||||
pub mod matrix;
|
||||
pub mod mattermost;
|
||||
pub mod nextcloud_talk;
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
pub mod nostr;
|
||||
pub mod qq;
|
||||
pub mod signal;
|
||||
@ -57,6 +58,7 @@ pub use linq::LinqChannel;
|
||||
pub use matrix::MatrixChannel;
|
||||
pub use mattermost::MattermostChannel;
|
||||
pub use nextcloud_talk::NextcloudTalkChannel;
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
pub use nostr::NostrChannel;
|
||||
pub use qq::QQChannel;
|
||||
pub use signal::SignalChannel;
|
||||
@ -74,6 +76,7 @@ use crate::agent::loop_::{build_tool_instructions, run_tool_call_loop, scrub_cre
|
||||
use crate::config::Config;
|
||||
use crate::identity;
|
||||
use crate::memory::{self, Memory};
|
||||
use crate::observability::traits::{ObserverEvent, ObserverMetric};
|
||||
use crate::observability::{self, runtime_trace, Observer};
|
||||
use crate::providers::{self, ChatMessage, Provider};
|
||||
use crate::runtime;
|
||||
@ -91,6 +94,66 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Observer wrapper that forwards tool-call events to a channel sender
|
||||
/// for real-time threaded notifications.
|
||||
struct ChannelNotifyObserver {
|
||||
inner: Arc<dyn Observer>,
|
||||
tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||
tools_used: AtomicBool,
|
||||
}
|
||||
|
||||
impl Observer for ChannelNotifyObserver {
|
||||
fn record_event(&self, event: &ObserverEvent) {
|
||||
if let ObserverEvent::ToolCallStart { tool, arguments } = event {
|
||||
self.tools_used.store(true, Ordering::Relaxed);
|
||||
let detail = match arguments {
|
||||
Some(args) if !args.is_empty() => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
|
||||
if let Some(cmd) = v.get("command").and_then(|c| c.as_str()) {
|
||||
format!(": `{}`", if cmd.len() > 200 { &cmd[..200] } else { cmd })
|
||||
} else if let Some(q) = v.get("query").and_then(|c| c.as_str()) {
|
||||
format!(": {}", if q.len() > 200 { &q[..200] } else { q })
|
||||
} else if let Some(p) = v.get("path").and_then(|c| c.as_str()) {
|
||||
format!(": {p}")
|
||||
} else if let Some(u) = v.get("url").and_then(|c| c.as_str()) {
|
||||
format!(": {u}")
|
||||
} else {
|
||||
let s = args.to_string();
|
||||
if s.len() > 120 {
|
||||
format!(": {}…", &s[..120])
|
||||
} else {
|
||||
format!(": {s}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let s = args.to_string();
|
||||
if s.len() > 120 {
|
||||
format!(": {}…", &s[..120])
|
||||
} else {
|
||||
format!(": {s}")
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let _ = self.tx.send(format!("\u{1F527} `{tool}`{detail}"));
|
||||
}
|
||||
self.inner.record_event(event);
|
||||
}
|
||||
fn record_metric(&self, metric: &ObserverMetric) {
|
||||
self.inner.record_metric(metric);
|
||||
}
|
||||
fn flush(&self) {
|
||||
self.inner.flush();
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
"channel-notify"
|
||||
}
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-sender conversation history for channel messages.
|
||||
type ConversationHistoryMap = Arc<Mutex<HashMap<String, Vec<ChatMessage>>>>;
|
||||
/// Maximum history messages to keep per sender.
|
||||
@ -227,6 +290,7 @@ struct ChannelRuntimeContext {
|
||||
multimodal: crate::config::MultimodalConfig,
|
||||
hooks: Option<Arc<crate::hooks::HookRunner>>,
|
||||
non_cli_excluded_tools: Arc<Vec<String>>,
|
||||
tool_call_dedup_exempt: Arc<Vec<String>>,
|
||||
model_routes: Arc<Vec<crate::config::ModelRouteConfig>>,
|
||||
}
|
||||
|
||||
@ -401,6 +465,13 @@ fn strip_tool_call_tags(message: &str) -> String {
|
||||
|
||||
fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> {
|
||||
match channel_name {
|
||||
"matrix" => Some(
|
||||
"When responding on Matrix:\n\
|
||||
- Use Markdown formatting (bold, italic, code blocks)\n\
|
||||
- Be concise and direct\n\
|
||||
- When you receive a [Voice message], the user spoke to you. Respond naturally as in conversation.\n\
|
||||
- Your text reply will automatically be converted to audio and sent back as a voice message.\n",
|
||||
),
|
||||
"telegram" => Some(
|
||||
"When responding on Telegram:\n\
|
||||
- Include media markers for files or URLs that should be sent as attachments\n\
|
||||
@ -426,6 +497,25 @@ fn build_channel_system_prompt(
|
||||
) -> String {
|
||||
let mut prompt = base_prompt.to_string();
|
||||
|
||||
// Refresh the stale datetime in the cached system prompt
|
||||
{
|
||||
let now = chrono::Local::now();
|
||||
let fresh = format!(
|
||||
"## Current Date & Time\n\n{} ({})\n",
|
||||
now.format("%Y-%m-%d %H:%M:%S"),
|
||||
now.format("%Z"),
|
||||
);
|
||||
if let Some(start) = prompt.find("## Current Date & Time\n\n") {
|
||||
// Find the end of this section (next "## " heading or end of string)
|
||||
let rest = &prompt[start + 24..]; // skip past "## Current Date & Time\n\n"
|
||||
let section_end = rest
|
||||
.find("\n## ")
|
||||
.map(|i| start + 24 + i)
|
||||
.unwrap_or(prompt.len());
|
||||
prompt.replace_range(start..section_end, fresh.trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(instructions) = channel_delivery_instructions(channel_name) {
|
||||
if prompt.is_empty() {
|
||||
prompt = instructions.to_string();
|
||||
@ -854,6 +944,14 @@ fn should_skip_memory_context_entry(key: &str, content: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip entries containing image markers to prevent duplication.
|
||||
// When auto_save stores a photo message to memory, a subsequent
|
||||
// memory recall on the same turn would surface the marker again,
|
||||
// causing two identical image blocks in the provider request.
|
||||
if content.contains("[IMAGE:") {
|
||||
return true;
|
||||
}
|
||||
|
||||
content.chars().count() > MEMORY_CONTEXT_MAX_CHARS
|
||||
}
|
||||
|
||||
@ -1582,7 +1680,7 @@ async fn process_channel_message(
|
||||
);
|
||||
|
||||
// ── Hook: on_message_received (modifying) ────────────
|
||||
let msg = if let Some(hooks) = &ctx.hooks {
|
||||
let mut msg = if let Some(hooks) = &ctx.hooks {
|
||||
match hooks.run_on_message_received(msg).await {
|
||||
crate::hooks::HookResult::Cancel(reason) => {
|
||||
tracing::info!(%reason, "incoming message dropped by hook");
|
||||
@ -1764,6 +1862,37 @@ async fn process_channel_message(
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Wrap observer to forward tool events as live thread messages
|
||||
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let notify_observer: Arc<ChannelNotifyObserver> = Arc::new(ChannelNotifyObserver {
|
||||
inner: Arc::clone(&ctx.observer),
|
||||
tx: notify_tx,
|
||||
tools_used: AtomicBool::new(false),
|
||||
});
|
||||
let notify_observer_flag = Arc::clone(¬ify_observer);
|
||||
let notify_channel = target_channel.clone();
|
||||
let notify_reply_target = msg.reply_target.clone();
|
||||
let notify_thread_root = msg.id.clone();
|
||||
let notify_task = if msg.channel == "cli" {
|
||||
Some(tokio::spawn(async move {
|
||||
while notify_rx.recv().await.is_some() {}
|
||||
}))
|
||||
} else {
|
||||
Some(tokio::spawn(async move {
|
||||
let thread_ts = Some(notify_thread_root);
|
||||
while let Some(text) = notify_rx.recv().await {
|
||||
if let Some(ref ch) = notify_channel {
|
||||
let _ = ch
|
||||
.send(
|
||||
&SendMessage::new(&text, ¬ify_reply_target)
|
||||
.in_thread(thread_ts.clone()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}))
|
||||
};
|
||||
|
||||
// Record history length before tool loop so we can extract tool context after.
|
||||
let history_len_before_tools = history.len();
|
||||
|
||||
@ -1782,7 +1911,7 @@ async fn process_channel_message(
|
||||
active_provider.as_ref(),
|
||||
&mut history,
|
||||
ctx.tools_registry.as_ref(),
|
||||
ctx.observer.as_ref(),
|
||||
notify_observer.as_ref() as &dyn Observer,
|
||||
route.provider.as_str(),
|
||||
route.model.as_str(),
|
||||
runtime_defaults.temperature,
|
||||
@ -1799,6 +1928,7 @@ async fn process_channel_message(
|
||||
} else {
|
||||
ctx.non_cli_excluded_tools.as_ref()
|
||||
},
|
||||
ctx.tool_call_dedup_exempt.as_ref(),
|
||||
),
|
||||
) => LlmExecutionResult::Completed(result),
|
||||
};
|
||||
@ -1807,6 +1937,17 @@ async fn process_channel_message(
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Thread the final reply only if tools were used (multi-message response)
|
||||
if notify_observer_flag.tools_used.load(Ordering::Relaxed) && msg.channel != "cli" {
|
||||
msg.thread_ts = Some(msg.id.clone());
|
||||
}
|
||||
// Drop the notify sender so the forwarder task finishes
|
||||
drop(notify_observer);
|
||||
drop(notify_observer_flag);
|
||||
if let Some(handle) = notify_task {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
if let Some(token) = typing_cancellation.as_ref() {
|
||||
token.cancel();
|
||||
}
|
||||
@ -2700,9 +2841,86 @@ pub(crate) async fn handle_command(command: crate::ChannelCommands, config: &Con
|
||||
crate::ChannelCommands::BindTelegram { identity } => {
|
||||
bind_telegram_identity(config, &identity).await
|
||||
}
|
||||
crate::ChannelCommands::Send {
|
||||
message,
|
||||
channel_id,
|
||||
recipient,
|
||||
} => send_channel_message(config, &channel_id, &recipient, &message).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a single channel instance by config section name (e.g. "telegram").
|
||||
fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Channel>> {
|
||||
match channel_id {
|
||||
"telegram" => {
|
||||
let tg = config
|
||||
.channels_config
|
||||
.telegram
|
||||
.as_ref()
|
||||
.context("Telegram channel is not configured")?;
|
||||
Ok(Arc::new(
|
||||
TelegramChannel::new(
|
||||
tg.bot_token.clone(),
|
||||
tg.allowed_users.clone(),
|
||||
tg.mention_only,
|
||||
)
|
||||
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
|
||||
.with_transcription(config.transcription.clone())
|
||||
.with_workspace_dir(config.workspace_dir.clone()),
|
||||
))
|
||||
}
|
||||
"discord" => {
|
||||
let dc = config
|
||||
.channels_config
|
||||
.discord
|
||||
.as_ref()
|
||||
.context("Discord channel is not configured")?;
|
||||
Ok(Arc::new(DiscordChannel::new(
|
||||
dc.bot_token.clone(),
|
||||
dc.guild_id.clone(),
|
||||
dc.allowed_users.clone(),
|
||||
dc.listen_to_bots,
|
||||
dc.mention_only,
|
||||
)))
|
||||
}
|
||||
"slack" => {
|
||||
let sl = config
|
||||
.channels_config
|
||||
.slack
|
||||
.as_ref()
|
||||
.context("Slack channel is not configured")?;
|
||||
Ok(Arc::new(
|
||||
SlackChannel::new(
|
||||
sl.bot_token.clone(),
|
||||
sl.app_token.clone(),
|
||||
sl.channel_id.clone(),
|
||||
Vec::new(),
|
||||
sl.allowed_users.clone(),
|
||||
)
|
||||
.with_workspace_dir(config.workspace_dir.clone()),
|
||||
))
|
||||
}
|
||||
other => anyhow::bail!("Unknown channel '{other}'. Supported: telegram, discord, slack"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a one-off message to a configured channel.
|
||||
async fn send_channel_message(
|
||||
config: &Config,
|
||||
channel_id: &str,
|
||||
recipient: &str,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let channel = build_channel_by_id(config, channel_id)?;
|
||||
let msg = SendMessage::new(message, recipient);
|
||||
channel
|
||||
.send(&msg)
|
||||
.await
|
||||
.with_context(|| format!("Failed to send message via {channel_id}"))?;
|
||||
println!("Message sent via {channel_id}.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ChannelHealthState {
|
||||
Healthy,
|
||||
@ -3018,8 +3236,10 @@ fn collect_configured_channels(
|
||||
|
||||
/// Run health checks for configured channels.
|
||||
pub async fn doctor_channels(config: Config) -> Result<()> {
|
||||
#[allow(unused_mut)]
|
||||
let mut channels = collect_configured_channels(&config, "health check");
|
||||
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
if let Some(ref ns) = config.channels_config.nostr {
|
||||
channels.push(ConfiguredChannel {
|
||||
display_name: "Nostr",
|
||||
@ -3084,6 +3304,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
};
|
||||
let provider: Arc<dyn Provider> = Arc::from(
|
||||
create_resilient_provider_nonblocking(
|
||||
@ -3255,12 +3476,14 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
}
|
||||
|
||||
// Collect active channels from a shared builder to keep startup and doctor parity.
|
||||
#[allow(unused_mut)]
|
||||
let mut channels: Vec<Arc<dyn Channel>> =
|
||||
collect_configured_channels(&config, "runtime startup")
|
||||
.into_iter()
|
||||
.map(|configured| configured.channel)
|
||||
.collect();
|
||||
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
if let Some(ref ns) = config.channels_config.nostr {
|
||||
channels.push(Arc::new(
|
||||
NostrChannel::new(&ns.private_key, ns.relays.clone(), &ns.allowed_pubkeys).await?,
|
||||
@ -3369,11 +3592,17 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
if config.hooks.builtin.command_logger {
|
||||
runner.register(Box::new(crate::hooks::builtin::CommandLoggerHook::new()));
|
||||
}
|
||||
if config.hooks.builtin.webhook_audit.enabled {
|
||||
runner.register(Box::new(crate::hooks::builtin::WebhookAuditHook::new(
|
||||
config.hooks.builtin.webhook_audit.clone(),
|
||||
)));
|
||||
}
|
||||
Some(Arc::new(runner))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()),
|
||||
tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()),
|
||||
model_routes: Arc::new(config.model_routes.clone()),
|
||||
});
|
||||
|
||||
@ -3474,6 +3703,22 @@ mod tests {
|
||||
"fabricated memory"
|
||||
));
|
||||
assert!(!should_skip_memory_context_entry("telegram_123_45", "hi"));
|
||||
|
||||
// Entries containing image markers must be skipped to prevent
|
||||
// auto-saved photo messages from duplicating image blocks (#2403).
|
||||
assert!(should_skip_memory_context_entry(
|
||||
"telegram_user_msg_99",
|
||||
"[IMAGE:/tmp/workspace/photo_1_2.jpg]"
|
||||
));
|
||||
assert!(should_skip_memory_context_entry(
|
||||
"telegram_user_msg_100",
|
||||
"[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nCheck this screenshot"
|
||||
));
|
||||
// Plain text without image markers should not be skipped.
|
||||
assert!(!should_skip_memory_context_entry(
|
||||
"telegram_user_msg_101",
|
||||
"Please describe the image"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -3588,16 +3833,17 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
};
|
||||
|
||||
assert!(compact_sender_history(&ctx, &sender));
|
||||
|
||||
let histories = ctx
|
||||
let locked_histories = ctx
|
||||
.conversation_histories
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let kept = histories
|
||||
let kept = locked_histories
|
||||
.get(&sender)
|
||||
.expect("sender history should remain");
|
||||
assert_eq!(kept.len(), CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES);
|
||||
@ -3638,6 +3884,7 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
};
|
||||
|
||||
@ -3691,16 +3938,17 @@ mod tests {
|
||||
workspace_dir: Arc::new(std::env::temp_dir()),
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
};
|
||||
|
||||
assert!(rollback_orphan_user_turn(&ctx, &sender, "pending"));
|
||||
|
||||
let histories = ctx
|
||||
let locked_histories = ctx
|
||||
.conversation_histories
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
let turns = histories
|
||||
let turns = locked_histories
|
||||
.get(&sender)
|
||||
.expect("sender history should remain");
|
||||
assert_eq!(turns.len(), 2);
|
||||
@ -4165,6 +4413,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
interrupt_on_new_message: false,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
@ -4186,11 +4435,12 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.await;
|
||||
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].starts_with("chat-42:"));
|
||||
assert!(sent_messages[0].contains("BTC is currently around"));
|
||||
assert!(!sent_messages[0].contains("\"tool_calls\""));
|
||||
assert!(!sent_messages[0].contains("mock_price"));
|
||||
assert!(!sent_messages.is_empty());
|
||||
let reply = sent_messages.last().unwrap();
|
||||
assert!(reply.starts_with("chat-42:"));
|
||||
assert!(reply.contains("BTC is currently around"));
|
||||
assert!(!reply.contains("\"tool_calls\""));
|
||||
assert!(!reply.contains("mock_price"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -4225,6 +4475,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS,
|
||||
interrupt_on_new_message: false,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
@ -4246,8 +4497,9 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.await;
|
||||
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].contains("BTC is currently around"));
|
||||
assert!(!sent_messages.is_empty());
|
||||
let reply = sent_messages.last().unwrap();
|
||||
assert!(reply.contains("BTC is currently around"));
|
||||
|
||||
let histories = runtime_ctx
|
||||
.conversation_histories
|
||||
@ -4301,6 +4553,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4361,6 +4614,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4380,11 +4634,12 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.await;
|
||||
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].starts_with("chat-84:"));
|
||||
assert!(sent_messages[0].contains("alias-tag flow resolved"));
|
||||
assert!(!sent_messages[0].contains("<toolcall>"));
|
||||
assert!(!sent_messages[0].contains("mock_price"));
|
||||
assert!(!sent_messages.is_empty());
|
||||
let reply = sent_messages.last().unwrap();
|
||||
assert!(reply.starts_with("chat-84:"));
|
||||
assert!(reply.contains("alias-tag flow resolved"));
|
||||
assert!(!reply.contains("<toolcall>"));
|
||||
assert!(!reply.contains("mock_price"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -4430,6 +4685,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4520,6 +4776,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4592,6 +4849,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4679,6 +4937,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4698,10 +4957,10 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut store = runtime_config_store()
|
||||
let mut cleanup_store = runtime_config_store()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
store.remove(&config_path);
|
||||
cleanup_store.remove(&config_path);
|
||||
}
|
||||
|
||||
assert_eq!(provider_impl.call_count.load(Ordering::SeqCst), 1);
|
||||
@ -4751,6 +5010,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4770,10 +5030,11 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.await;
|
||||
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].starts_with("chat-iter-success:"));
|
||||
assert!(sent_messages[0].contains("Completed after 11 tool iterations."));
|
||||
assert!(!sent_messages[0].contains("⚠️ Error:"));
|
||||
assert!(!sent_messages.is_empty());
|
||||
let reply = sent_messages.last().unwrap();
|
||||
assert!(reply.starts_with("chat-iter-success:"));
|
||||
assert!(reply.contains("Completed after 11 tool iterations."));
|
||||
assert!(!reply.contains("⚠️ Error:"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -4812,6 +5073,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -4831,9 +5093,10 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
.await;
|
||||
|
||||
let sent_messages = channel_impl.sent_messages.lock().await;
|
||||
assert_eq!(sent_messages.len(), 1);
|
||||
assert!(sent_messages[0].starts_with("chat-iter-fail:"));
|
||||
assert!(sent_messages[0].contains("⚠️ Error: Agent exceeded maximum tool iterations (3)"));
|
||||
assert!(!sent_messages.is_empty());
|
||||
let reply = sent_messages.last().unwrap();
|
||||
assert!(reply.starts_with("chat-iter-fail:"));
|
||||
assert!(reply.contains("⚠️ Error: Agent exceeded maximum tool iterations (3)"));
|
||||
}
|
||||
|
||||
struct NoopMemory;
|
||||
@ -4984,6 +5247,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5065,6 +5329,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5158,6 +5423,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5233,6 +5499,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5293,6 +5560,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5774,6 +6042,47 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
assert!(context.contains("Age is 45"));
|
||||
}
|
||||
|
||||
/// Auto-saved photo messages must not surface through memory context,
|
||||
/// otherwise the image marker gets duplicated in the provider request (#2403).
|
||||
#[tokio::test]
|
||||
async fn build_memory_context_excludes_image_marker_entries() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mem = SqliteMemory::new(tmp.path()).unwrap();
|
||||
|
||||
// Simulate auto-save of a photo message containing an [IMAGE:] marker.
|
||||
mem.store(
|
||||
"telegram_user_msg_photo",
|
||||
"[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nDescribe this screenshot",
|
||||
MemoryCategory::Conversation,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// Also store a plain text entry that shares a word with the query
|
||||
// so the FTS recall returns both entries.
|
||||
mem.store(
|
||||
"screenshot_preference",
|
||||
"User prefers screenshot descriptions to be concise",
|
||||
MemoryCategory::Conversation,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let context = build_memory_context(&mem, "screenshot", 0.0).await;
|
||||
|
||||
// The image-marker entry must be excluded to prevent duplication.
|
||||
assert!(
|
||||
!context.contains("[IMAGE:"),
|
||||
"memory context must not contain image markers, got: {context}"
|
||||
);
|
||||
// Plain text entries should still be included.
|
||||
assert!(
|
||||
context.contains("screenshot descriptions"),
|
||||
"plain text entry should remain in context, got: {context}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
|
||||
let channel_impl = Arc::new(RecordingChannel::default());
|
||||
@ -5810,6 +6119,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5896,6 +6206,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -5982,6 +6293,7 @@ BTC is currently around $65,000 based on latest tool output."#
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -6532,6 +6844,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -6599,6 +6912,7 @@ This is an example JSON object for profile settings."#;
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
hooks: None,
|
||||
non_cli_excluded_tools: Arc::new(Vec::new()),
|
||||
tool_call_dedup_exempt: Arc::new(Vec::new()),
|
||||
model_routes: Arc::new(Vec::new()),
|
||||
});
|
||||
|
||||
@ -6663,4 +6977,51 @@ This is an example JSON object for profile settings."#;
|
||||
"failed vision turn must not persist image marker content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_by_id_unknown_channel_returns_error() {
|
||||
let config = Config::default();
|
||||
match build_channel_by_id(&config, "nonexistent") {
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
assert!(
|
||||
err_msg.contains("Unknown channel"),
|
||||
"expected 'Unknown channel' in error, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("should fail for unknown channel"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_by_id_unconfigured_telegram_returns_error() {
|
||||
let config = Config::default();
|
||||
match build_channel_by_id(&config, "telegram") {
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
assert!(
|
||||
err_msg.contains("not configured"),
|
||||
"expected 'not configured' in error, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
Ok(_) => panic!("should fail when telegram is not configured"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_channel_by_id_configured_telegram_succeeds() {
|
||||
let mut config = Config::default();
|
||||
config.channels_config.telegram = Some(crate::config::schema::TelegramConfig {
|
||||
bot_token: "test-token".to_string(),
|
||||
allowed_users: vec![],
|
||||
stream_mode: crate::config::StreamMode::Off,
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
});
|
||||
match build_channel_by_id(&config, "telegram") {
|
||||
Ok(channel) => assert_eq!(channel.name(), "telegram"),
|
||||
Err(e) => panic!("should succeed when telegram is configured: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,6 +142,16 @@ pub trait Channel: Send + Sync {
|
||||
) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pin a message in the channel.
|
||||
async fn pin_message(&self, _channel_id: &str, _message_id: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unpin a previously pinned message.
|
||||
async fn unpin_message(&self, _channel_id: &str, _message_id: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@ -233,6 +233,48 @@ impl WhatsAppWebChannel {
|
||||
|
||||
Ok(wa_rs_binary::jid::Jid::pn(digits))
|
||||
}
|
||||
|
||||
// ── Reconnect state-machine helpers (used by listen() and tested directly) ──
|
||||
|
||||
/// Reconnect retry constants.
|
||||
const MAX_RETRIES: u32 = 10;
|
||||
const BASE_DELAY_SECS: u64 = 3;
|
||||
const MAX_DELAY_SECS: u64 = 300;
|
||||
|
||||
/// Compute the exponential-backoff delay for a given 1-based attempt number.
|
||||
/// Doubles each attempt from `BASE_DELAY_SECS`, capped at `MAX_DELAY_SECS`.
|
||||
fn compute_retry_delay(attempt: u32) -> u64 {
|
||||
std::cmp::min(
|
||||
Self::BASE_DELAY_SECS.saturating_mul(2u64.saturating_pow(attempt.saturating_sub(1))),
|
||||
Self::MAX_DELAY_SECS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Determine whether session files should be purged.
|
||||
/// Returns `true` only when `Event::LoggedOut` was explicitly observed.
|
||||
fn should_purge_session(session_revoked: &std::sync::atomic::AtomicBool) -> bool {
|
||||
session_revoked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Record a reconnect attempt and return `(attempt_number, exceeded_max)`.
|
||||
fn record_retry(retry_count: &std::sync::atomic::AtomicU32) -> (u32, bool) {
|
||||
let attempts = retry_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
(attempts, attempts > Self::MAX_RETRIES)
|
||||
}
|
||||
|
||||
/// Reset the retry counter (called on `Event::Connected`).
|
||||
fn reset_retry(retry_count: &std::sync::atomic::AtomicU32) {
|
||||
retry_count.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Return the session file paths to remove (primary + WAL + SHM sidecars).
|
||||
fn session_file_paths(expanded_session_path: &str) -> [String; 3] {
|
||||
[
|
||||
expanded_session_path.to_string(),
|
||||
format!("{expanded_session_path}-wal"),
|
||||
format!("{expanded_session_path}-shm"),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
@ -288,198 +330,288 @@ impl Channel for WhatsAppWebChannel {
|
||||
use wa_rs_tokio_transport::TokioWebSocketTransportFactory;
|
||||
use wa_rs_ureq_http::UreqHttpClient;
|
||||
|
||||
tracing::info!(
|
||||
"WhatsApp Web channel starting (session: {})",
|
||||
self.session_path
|
||||
);
|
||||
let retry_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||
|
||||
// Initialize storage backend
|
||||
let storage = RusqliteStore::new(&self.session_path)?;
|
||||
let backend = Arc::new(storage);
|
||||
loop {
|
||||
let expanded_session_path = shellexpand::tilde(&self.session_path).to_string();
|
||||
|
||||
// Check if we have a saved device to load
|
||||
let mut device = Device::new(backend.clone());
|
||||
if backend.exists().await? {
|
||||
tracing::info!("WhatsApp Web: found existing session, loading device");
|
||||
if let Some(core_device) = backend.load().await? {
|
||||
device.load_from_serializable(core_device);
|
||||
} else {
|
||||
anyhow::bail!("Device exists but failed to load");
|
||||
}
|
||||
} else {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: no existing session, new device will be created during pairing"
|
||||
"WhatsApp Web channel starting (session: {})",
|
||||
expanded_session_path
|
||||
);
|
||||
};
|
||||
|
||||
// Create transport factory
|
||||
let mut transport_factory = TokioWebSocketTransportFactory::new();
|
||||
if let Ok(ws_url) = std::env::var("WHATSAPP_WS_URL") {
|
||||
transport_factory = transport_factory.with_url(ws_url);
|
||||
}
|
||||
// Initialize storage backend
|
||||
let storage = RusqliteStore::new(&expanded_session_path)?;
|
||||
let backend = Arc::new(storage);
|
||||
|
||||
// Create HTTP client for media operations
|
||||
let http_client = UreqHttpClient::new();
|
||||
|
||||
// Build the bot
|
||||
let tx_clone = tx.clone();
|
||||
let allowed_numbers = self.allowed_numbers.clone();
|
||||
|
||||
let mut builder = Bot::builder()
|
||||
.with_backend(backend)
|
||||
.with_transport_factory(transport_factory)
|
||||
.with_http_client(http_client)
|
||||
.on_event(move |event, _client| {
|
||||
let tx_inner = tx_clone.clone();
|
||||
let allowed_numbers = allowed_numbers.clone();
|
||||
async move {
|
||||
match event {
|
||||
Event::Message(msg, info) => {
|
||||
// Extract message content
|
||||
let text = msg.text_content().unwrap_or("");
|
||||
let sender_jid = info.source.sender.clone();
|
||||
let sender_alt = info.source.sender_alt.clone();
|
||||
let sender = sender_jid.user().to_string();
|
||||
let chat = info.source.chat.to_string();
|
||||
|
||||
tracing::info!(
|
||||
"WhatsApp Web message from {} in {}: {}",
|
||||
sender,
|
||||
chat,
|
||||
text
|
||||
);
|
||||
|
||||
let mapped_phone = if sender_jid.is_lid() {
|
||||
_client.get_phone_number_from_lid(&sender_jid.user).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sender_candidates = Self::sender_phone_candidates(
|
||||
&sender_jid,
|
||||
sender_alt.as_ref(),
|
||||
mapped_phone.as_deref(),
|
||||
);
|
||||
|
||||
if let Some(normalized) = sender_candidates
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
Self::is_number_allowed_for_list(&allowed_numbers, candidate)
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
tracing::debug!(
|
||||
"WhatsApp Web: ignoring empty or non-text message from {}",
|
||||
normalized
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = tx_inner
|
||||
.send(ChannelMessage {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
channel: "whatsapp".to_string(),
|
||||
sender: normalized.clone(),
|
||||
// Reply to the originating chat JID (DM or group).
|
||||
reply_target: chat,
|
||||
content: trimmed.to_string(),
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
thread_ts: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send message to channel: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: message from {} not in allowed list (candidates: {:?})",
|
||||
sender_jid,
|
||||
sender_candidates
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Connected(_) => {
|
||||
tracing::info!("WhatsApp Web connected successfully");
|
||||
}
|
||||
Event::LoggedOut(_) => {
|
||||
tracing::warn!("WhatsApp Web was logged out");
|
||||
}
|
||||
Event::StreamError(stream_error) => {
|
||||
tracing::error!("WhatsApp Web stream error: {:?}", stream_error);
|
||||
}
|
||||
Event::PairingCode { code, .. } => {
|
||||
tracing::info!("WhatsApp Web pair code received: {}", code);
|
||||
tracing::info!(
|
||||
"Link your phone by entering this code in WhatsApp > Linked Devices"
|
||||
);
|
||||
}
|
||||
Event::PairingQrCode { code, .. } => {
|
||||
tracing::info!(
|
||||
"WhatsApp Web QR code received (scan with WhatsApp > Linked Devices)"
|
||||
);
|
||||
match Self::render_pairing_qr(&code) {
|
||||
Ok(rendered) => {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"WhatsApp Web QR code (scan in WhatsApp > Linked Devices):"
|
||||
);
|
||||
eprintln!("{rendered}");
|
||||
eprintln!();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: failed to render pairing QR in terminal: {}",
|
||||
err
|
||||
);
|
||||
tracing::info!("WhatsApp Web QR payload: {}", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Check if we have a saved device to load
|
||||
let mut device = Device::new(backend.clone());
|
||||
if backend.exists().await? {
|
||||
tracing::info!("WhatsApp Web: found existing session, loading device");
|
||||
if let Some(core_device) = backend.load().await? {
|
||||
device.load_from_serializable(core_device);
|
||||
} else {
|
||||
anyhow::bail!("Device exists but failed to load");
|
||||
}
|
||||
})
|
||||
;
|
||||
} else {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: no existing session, new device will be created during pairing"
|
||||
);
|
||||
};
|
||||
|
||||
// Configure pair-code flow when a phone number is provided.
|
||||
if let Some(ref phone) = self.pair_phone {
|
||||
tracing::info!("WhatsApp Web: pair-code flow enabled for configured phone number");
|
||||
builder = builder.with_pair_code(PairCodeOptions {
|
||||
phone_number: phone.clone(),
|
||||
custom_code: self.pair_code.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
} else if self.pair_code.is_some() {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: pair_code is set but pair_phone is missing; pair code config is ignored"
|
||||
);
|
||||
}
|
||||
|
||||
let mut bot = builder.build().await?;
|
||||
*self.client.lock() = Some(bot.client());
|
||||
|
||||
// Run the bot
|
||||
let bot_handle = bot.run().await?;
|
||||
|
||||
// Store the bot handle for later shutdown
|
||||
*self.bot_handle.lock() = Some(bot_handle);
|
||||
|
||||
// Wait for shutdown signal
|
||||
let (_shutdown_tx, mut shutdown_rx) = tokio::sync::broadcast::channel::<()>(1);
|
||||
|
||||
select! {
|
||||
_ = shutdown_rx.recv() => {
|
||||
tracing::info!("WhatsApp Web channel shutting down");
|
||||
// Create transport factory
|
||||
let mut transport_factory = TokioWebSocketTransportFactory::new();
|
||||
if let Ok(ws_url) = std::env::var("WHATSAPP_WS_URL") {
|
||||
transport_factory = transport_factory.with_url(ws_url);
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("WhatsApp Web channel received Ctrl+C");
|
||||
}
|
||||
}
|
||||
|
||||
*self.client.lock() = None;
|
||||
if let Some(handle) = self.bot_handle.lock().take() {
|
||||
handle.abort();
|
||||
// Create HTTP client for media operations
|
||||
let http_client = UreqHttpClient::new();
|
||||
|
||||
// Channel to signal logout from the event handler back to the listen loop.
|
||||
let (logout_tx, mut logout_rx) = tokio::sync::broadcast::channel::<()>(1);
|
||||
|
||||
// Tracks whether Event::LoggedOut actually fired (vs task crash).
|
||||
let session_revoked = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
|
||||
// Build the bot
|
||||
let tx_clone = tx.clone();
|
||||
let allowed_numbers = self.allowed_numbers.clone();
|
||||
let logout_tx_clone = logout_tx.clone();
|
||||
let retry_count_clone = retry_count.clone();
|
||||
let session_revoked_clone = session_revoked.clone();
|
||||
|
||||
let mut builder = Bot::builder()
|
||||
.with_backend(backend)
|
||||
.with_transport_factory(transport_factory)
|
||||
.with_http_client(http_client)
|
||||
.on_event(move |event, _client| {
|
||||
let tx_inner = tx_clone.clone();
|
||||
let allowed_numbers = allowed_numbers.clone();
|
||||
let logout_tx = logout_tx_clone.clone();
|
||||
let retry_count = retry_count_clone.clone();
|
||||
let session_revoked = session_revoked_clone.clone();
|
||||
async move {
|
||||
match event {
|
||||
Event::Message(msg, info) => {
|
||||
// Extract message content
|
||||
let text = msg.text_content().unwrap_or("");
|
||||
let sender_jid = info.source.sender.clone();
|
||||
let sender_alt = info.source.sender_alt.clone();
|
||||
let sender = sender_jid.user().to_string();
|
||||
let chat = info.source.chat.to_string();
|
||||
|
||||
tracing::info!(
|
||||
"WhatsApp Web message received (sender_len={}, chat_len={}, text_len={})",
|
||||
sender.len(),
|
||||
chat.len(),
|
||||
text.len()
|
||||
);
|
||||
tracing::debug!(
|
||||
"WhatsApp Web message content: {}",
|
||||
text
|
||||
);
|
||||
|
||||
let mapped_phone = if sender_jid.is_lid() {
|
||||
_client.get_phone_number_from_lid(&sender_jid.user).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let sender_candidates = Self::sender_phone_candidates(
|
||||
&sender_jid,
|
||||
sender_alt.as_ref(),
|
||||
mapped_phone.as_deref(),
|
||||
);
|
||||
|
||||
if let Some(normalized) = sender_candidates
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
Self::is_number_allowed_for_list(&allowed_numbers, candidate)
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
tracing::debug!(
|
||||
"WhatsApp Web: ignoring empty or non-text message from {}",
|
||||
normalized
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = tx_inner
|
||||
.send(ChannelMessage {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
channel: "whatsapp".to_string(),
|
||||
sender: normalized.clone(),
|
||||
// Reply to the originating chat JID (DM or group).
|
||||
reply_target: chat,
|
||||
content: trimmed.to_string(),
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
thread_ts: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send message to channel: {}", e);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: message from unrecognized sender not in allowed list (candidates_count={})",
|
||||
sender_candidates.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Connected(_) => {
|
||||
tracing::info!("WhatsApp Web connected successfully");
|
||||
WhatsAppWebChannel::reset_retry(&retry_count);
|
||||
}
|
||||
Event::LoggedOut(_) => {
|
||||
session_revoked.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
tracing::warn!(
|
||||
"WhatsApp Web was logged out — will clear session and reconnect"
|
||||
);
|
||||
let _ = logout_tx.send(());
|
||||
}
|
||||
Event::StreamError(stream_error) => {
|
||||
tracing::error!("WhatsApp Web stream error: {:?}", stream_error);
|
||||
}
|
||||
Event::PairingCode { code, .. } => {
|
||||
tracing::info!("WhatsApp Web pair code received");
|
||||
tracing::info!(
|
||||
"Link your phone by entering this code in WhatsApp > Linked Devices"
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("WhatsApp Web pair code: {code}");
|
||||
eprintln!();
|
||||
}
|
||||
Event::PairingQrCode { code, .. } => {
|
||||
tracing::info!(
|
||||
"WhatsApp Web QR code received (scan with WhatsApp > Linked Devices)"
|
||||
);
|
||||
match Self::render_pairing_qr(&code) {
|
||||
Ok(rendered) => {
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"WhatsApp Web QR code (scan in WhatsApp > Linked Devices):"
|
||||
);
|
||||
eprintln!("{rendered}");
|
||||
eprintln!();
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: failed to render pairing QR in terminal: {}",
|
||||
err
|
||||
);
|
||||
eprintln!();
|
||||
eprintln!("WhatsApp Web QR payload: {code}");
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Configure pair-code flow when a phone number is provided.
|
||||
if let Some(ref phone) = self.pair_phone {
|
||||
tracing::info!("WhatsApp Web: pair-code flow enabled for configured phone number");
|
||||
builder = builder.with_pair_code(PairCodeOptions {
|
||||
phone_number: phone.clone(),
|
||||
custom_code: self.pair_code.clone(),
|
||||
..Default::default()
|
||||
});
|
||||
} else if self.pair_code.is_some() {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: pair_code is set but pair_phone is missing; pair code config is ignored"
|
||||
);
|
||||
}
|
||||
|
||||
let mut bot = builder.build().await?;
|
||||
*self.client.lock() = Some(bot.client());
|
||||
|
||||
// Run the bot
|
||||
let bot_handle = bot.run().await?;
|
||||
|
||||
// Store the bot handle for later shutdown
|
||||
*self.bot_handle.lock() = Some(bot_handle);
|
||||
|
||||
// Drop the outer sender so logout_rx.recv() returns Err when the
|
||||
// bot task ends without emitting LoggedOut (e.g. crash/panic).
|
||||
drop(logout_tx);
|
||||
|
||||
// Wait for a logout signal or process shutdown.
|
||||
let should_reconnect = select! {
|
||||
res = logout_rx.recv() => {
|
||||
// Both Ok(()) and Err (sender dropped) mean the session ended.
|
||||
let _ = res;
|
||||
true
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("WhatsApp Web channel received Ctrl+C");
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
*self.client.lock() = None;
|
||||
let handle = self.bot_handle.lock().take();
|
||||
if let Some(handle) = handle {
|
||||
handle.abort();
|
||||
// Await the aborted task so background I/O finishes before
|
||||
// we delete session files.
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
// Drop bot/device so the SQLite connection is closed
|
||||
// before we remove session files (releases WAL/SHM locks).
|
||||
// `backend` was moved into the builder, so dropping `bot`
|
||||
// releases the last Arc reference to the storage backend.
|
||||
drop(bot);
|
||||
drop(device);
|
||||
|
||||
if should_reconnect {
|
||||
let (attempts, exceeded) = Self::record_retry(&retry_count);
|
||||
if exceeded {
|
||||
anyhow::bail!(
|
||||
"WhatsApp Web: exceeded {} reconnect attempts, giving up",
|
||||
Self::MAX_RETRIES
|
||||
);
|
||||
}
|
||||
|
||||
// Only purge session files when LoggedOut was explicitly observed.
|
||||
// A transient task crash (Err from recv) should not wipe a valid session.
|
||||
if Self::should_purge_session(&session_revoked) {
|
||||
for path in Self::session_file_paths(&expanded_session_path) {
|
||||
match tokio::fs::remove_file(&path).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => tracing::warn!(
|
||||
"WhatsApp Web: failed to remove session file {}: {e}",
|
||||
path
|
||||
),
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
"WhatsApp Web: session files removed, restarting for QR pairing"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: bot stopped without LoggedOut; reconnecting with existing session"
|
||||
);
|
||||
}
|
||||
|
||||
let delay = Self::compute_retry_delay(attempts);
|
||||
tracing::info!(
|
||||
"WhatsApp Web: reconnecting in {}s (attempt {}/{})",
|
||||
delay,
|
||||
attempts,
|
||||
Self::MAX_RETRIES
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@ -720,4 +852,101 @@ mod tests {
|
||||
let ch = make_channel();
|
||||
assert!(!ch.health_check().await);
|
||||
}
|
||||
|
||||
// ── Reconnect retry state machine tests (exercise production helpers) ──
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn compute_retry_delay_doubles_with_cap() {
|
||||
// Uses the production helper that listen() calls for backoff.
|
||||
// attempt 1 → 3s, 2 → 6s, 3 → 12s, … 7 → 192s, 8 → 300s (capped)
|
||||
let expected = [3, 6, 12, 24, 48, 96, 192, 300, 300, 300];
|
||||
for (i, &want) in expected.iter().enumerate() {
|
||||
let attempt = (i + 1) as u32;
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::compute_retry_delay(attempt),
|
||||
want,
|
||||
"attempt {attempt}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn compute_retry_delay_zero_attempt() {
|
||||
// Edge case: attempt 0 should still produce BASE (saturating_sub clamps).
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::compute_retry_delay(0),
|
||||
WhatsAppWebChannel::BASE_DELAY_SECS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn record_retry_increments_and_detects_exceeded() {
|
||||
use std::sync::atomic::AtomicU32;
|
||||
let counter = AtomicU32::new(0);
|
||||
|
||||
// First MAX_RETRIES attempts should not exceed.
|
||||
for i in 1..=WhatsAppWebChannel::MAX_RETRIES {
|
||||
let (attempt, exceeded) = WhatsAppWebChannel::record_retry(&counter);
|
||||
assert_eq!(attempt, i);
|
||||
assert!(!exceeded, "attempt {i} should not exceed max");
|
||||
}
|
||||
|
||||
// Next attempt exceeds the limit.
|
||||
let (attempt, exceeded) = WhatsAppWebChannel::record_retry(&counter);
|
||||
assert_eq!(attempt, WhatsAppWebChannel::MAX_RETRIES + 1);
|
||||
assert!(exceeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn reset_retry_clears_counter() {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
let counter = AtomicU32::new(0);
|
||||
|
||||
// Simulate several reconnect attempts via the production helper.
|
||||
for _ in 0..5 {
|
||||
WhatsAppWebChannel::record_retry(&counter);
|
||||
}
|
||||
assert_eq!(counter.load(Ordering::Relaxed), 5);
|
||||
|
||||
// Event::Connected calls reset_retry — verify it zeroes the counter.
|
||||
WhatsAppWebChannel::reset_retry(&counter);
|
||||
assert_eq!(counter.load(Ordering::Relaxed), 0);
|
||||
|
||||
// After reset, record_retry starts from 1 again.
|
||||
let (attempt, exceeded) = WhatsAppWebChannel::record_retry(&counter);
|
||||
assert_eq!(attempt, 1);
|
||||
assert!(!exceeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn should_purge_session_only_when_revoked() {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
let flag = AtomicBool::new(false);
|
||||
|
||||
// Transient crash: flag is false → should NOT purge.
|
||||
assert!(!WhatsAppWebChannel::should_purge_session(&flag));
|
||||
|
||||
// Explicit LoggedOut: flag set to true → should purge.
|
||||
flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
assert!(WhatsAppWebChannel::should_purge_session(&flag));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn session_file_paths_includes_wal_and_shm() {
|
||||
let paths = WhatsAppWebChannel::session_file_paths("/tmp/test.db");
|
||||
assert_eq!(
|
||||
paths,
|
||||
[
|
||||
"/tmp/test.db".to_string(),
|
||||
"/tmp/test.db-wal".to_string(),
|
||||
"/tmp/test.db-shm".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -90,6 +90,13 @@ pub struct Config {
|
||||
)]
|
||||
pub default_temperature: f64,
|
||||
|
||||
/// HTTP request timeout in seconds for LLM provider API calls. Default: `120`.
|
||||
///
|
||||
/// Increase for slower backends (e.g., llama.cpp on constrained hardware)
|
||||
/// that need more time processing large contexts.
|
||||
#[serde(default = "default_provider_timeout_secs")]
|
||||
pub provider_timeout_secs: u64,
|
||||
|
||||
/// Observability backend configuration (`[observability]`).
|
||||
#[serde(default)]
|
||||
pub observability: ObservabilityConfig,
|
||||
@ -242,6 +249,15 @@ pub struct ModelProviderConfig {
|
||||
/// If true, load OpenAI auth material (OPENAI_API_KEY or ~/.codex/auth.json).
|
||||
#[serde(default)]
|
||||
pub requires_openai_auth: bool,
|
||||
/// Azure OpenAI resource name (e.g. "my-resource" in https://my-resource.openai.azure.com).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub azure_openai_resource: Option<String>,
|
||||
/// Azure OpenAI deployment name (e.g. "gpt-4o").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub azure_openai_deployment: Option<String>,
|
||||
/// Azure OpenAI API version (defaults to "2024-08-01-preview").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub azure_openai_api_version: Option<String>,
|
||||
}
|
||||
|
||||
// ── Delegate Agents ──────────────────────────────────────────────
|
||||
@ -286,6 +302,13 @@ fn default_temperature() -> f64 {
|
||||
DEFAULT_TEMPERATURE
|
||||
}
|
||||
|
||||
/// Default provider HTTP request timeout: 120 seconds.
|
||||
const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
fn default_provider_timeout_secs() -> u64 {
|
||||
DEFAULT_PROVIDER_TIMEOUT_SECS
|
||||
}
|
||||
|
||||
/// Validate that a temperature value is within the allowed range.
|
||||
pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
|
||||
if TEMPERATURE_RANGE.contains(&value) {
|
||||
@ -595,6 +618,9 @@ pub struct AgentConfig {
|
||||
/// Tool dispatch strategy (e.g. `"auto"`). Default: `"auto"`.
|
||||
#[serde(default = "default_agent_tool_dispatcher")]
|
||||
pub tool_dispatcher: String,
|
||||
/// Tools exempt from the within-turn duplicate-call dedup check. Default: `[]`.
|
||||
#[serde(default)]
|
||||
pub tool_call_dedup_exempt: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_agent_max_tool_iterations() -> usize {
|
||||
@ -617,6 +643,7 @@ impl Default for AgentConfig {
|
||||
max_history_messages: default_agent_max_history_messages(),
|
||||
parallel_tools: false,
|
||||
tool_dispatcher: default_agent_tool_dispatcher(),
|
||||
tool_call_dedup_exempt: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1234,7 +1261,7 @@ pub struct WebFetchConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_web_fetch_allowed_domains")]
|
||||
pub allowed_domains: Vec<String>,
|
||||
/// Blocked domains (exact or subdomain match; always takes priority over allowed_domains)
|
||||
#[serde(default)]
|
||||
@ -1255,6 +1282,10 @@ fn default_web_fetch_timeout_secs() -> u64 {
|
||||
30
|
||||
}
|
||||
|
||||
fn default_web_fetch_allowed_domains() -> Vec<String> {
|
||||
vec!["*".into()]
|
||||
}
|
||||
|
||||
impl Default for WebFetchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@ -2118,6 +2149,57 @@ impl Default for HooksConfig {
|
||||
pub struct BuiltinHooksConfig {
|
||||
/// Enable the command-logger hook (logs tool calls for auditing).
|
||||
pub command_logger: bool,
|
||||
/// Configuration for the webhook-audit hook.
|
||||
///
|
||||
/// When enabled, POSTs a JSON payload to `url` for every tool invocation
|
||||
/// that matches one of `tool_patterns`.
|
||||
#[serde(default)]
|
||||
pub webhook_audit: WebhookAuditConfig,
|
||||
}
|
||||
|
||||
/// Configuration for the webhook-audit builtin hook.
|
||||
///
|
||||
/// Sends an HTTP POST with a JSON body to an external endpoint each time
|
||||
/// a tool call matches one of the configured patterns. Useful for
|
||||
/// centralised audit logging, SIEM ingestion, or compliance pipelines.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WebhookAuditConfig {
|
||||
/// Enable the webhook-audit hook. Default: `false`.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
/// Target URL that will receive the audit POST requests.
|
||||
#[serde(default)]
|
||||
pub url: String,
|
||||
/// Glob patterns for tool names to audit (e.g. `["Bash", "Write"]`).
|
||||
/// An empty list means **no** tools are audited.
|
||||
#[serde(default)]
|
||||
pub tool_patterns: Vec<String>,
|
||||
/// Include tool call arguments in the audit payload. Default: `false`.
|
||||
///
|
||||
/// Be mindful of sensitive data — arguments may contain secrets or PII.
|
||||
#[serde(default)]
|
||||
pub include_args: bool,
|
||||
/// Maximum size (in bytes) of serialised arguments included in a single
|
||||
/// audit payload. Arguments exceeding this limit are truncated.
|
||||
/// Default: `4096`.
|
||||
#[serde(default = "default_max_args_bytes")]
|
||||
pub max_args_bytes: u64,
|
||||
}
|
||||
|
||||
fn default_max_args_bytes() -> u64 {
|
||||
4096
|
||||
}
|
||||
|
||||
impl Default for WebhookAuditConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
url: String::new(),
|
||||
tool_patterns: Vec::new(),
|
||||
include_args: false,
|
||||
max_args_bytes: default_max_args_bytes(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autonomy / Security ──────────────────────────────────────────
|
||||
@ -2752,6 +2834,7 @@ pub struct ChannelsConfig {
|
||||
pub dingtalk: Option<DingTalkConfig>,
|
||||
/// QQ Official Bot channel configuration.
|
||||
pub qq: Option<QQConfig>,
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
pub nostr: Option<NostrConfig>,
|
||||
/// ClawdTalk voice channel configuration.
|
||||
pub clawdtalk: Option<crate::channels::ClawdTalkConfig>,
|
||||
@ -2837,6 +2920,7 @@ impl ChannelsConfig {
|
||||
Box::new(ConfigWrapper::new(self.qq.as_ref())),
|
||||
self.qq.is_some()
|
||||
),
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
(
|
||||
Box::new(ConfigWrapper::new(self.nostr.as_ref())),
|
||||
self.nostr.is_some(),
|
||||
@ -2884,6 +2968,7 @@ impl Default for ChannelsConfig {
|
||||
feishu: None,
|
||||
dingtalk: None,
|
||||
qq: None,
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
nostr: None,
|
||||
clawdtalk: None,
|
||||
message_timeout_secs: default_channel_message_timeout_secs(),
|
||||
@ -3715,6 +3800,7 @@ impl ChannelConfig for QQConfig {
|
||||
}
|
||||
|
||||
/// Nostr channel configuration (NIP-04 + NIP-17 private messages)
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct NostrConfig {
|
||||
/// Private key in hex or nsec bech32 format
|
||||
@ -3727,6 +3813,7 @@ pub struct NostrConfig {
|
||||
pub allowed_pubkeys: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
impl ChannelConfig for NostrConfig {
|
||||
fn name() -> &'static str {
|
||||
"Nostr"
|
||||
@ -3736,6 +3823,7 @@ impl ChannelConfig for NostrConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
pub fn default_nostr_relays() -> Vec<String> {
|
||||
vec![
|
||||
"wss://relay.damus.io".to_string(),
|
||||
@ -3762,6 +3850,7 @@ impl Default for Config {
|
||||
default_model: Some("anthropic/claude-sonnet-4.6".to_string()),
|
||||
model_providers: HashMap::new(),
|
||||
default_temperature: default_temperature(),
|
||||
provider_timeout_secs: default_provider_timeout_secs(),
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
security: SecurityConfig::default(),
|
||||
@ -3981,7 +4070,7 @@ pub(crate) fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf
|
||||
///
|
||||
/// This mirrors the same precedence used by `Config::load_or_init()`:
|
||||
/// `ZEROCLAW_CONFIG_DIR` > `ZEROCLAW_WORKSPACE` > active workspace marker > defaults.
|
||||
pub(crate) async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
|
||||
pub async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
|
||||
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
|
||||
let (config_dir, workspace_dir, _) =
|
||||
resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
|
||||
@ -4150,10 +4239,13 @@ fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
|
||||
|
||||
fn normalize_wire_api(raw: &str) -> Option<&'static str> {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"responses" => Some("responses"),
|
||||
"chat_completions" | "chat-completions" | "chat" | "chatcompletions" => {
|
||||
Some("chat_completions")
|
||||
}
|
||||
"responses" | "openai-responses" | "open-ai-responses" => Some("responses"),
|
||||
"chat_completions"
|
||||
| "chat-completions"
|
||||
| "chat"
|
||||
| "chatcompletions"
|
||||
| "openai-chat-completions"
|
||||
| "open-ai-chat-completions" => Some("chat_completions"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@ -4276,6 +4368,7 @@ impl Config {
|
||||
decrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
if let Some(ref mut ns) = config.channels_config.nostr {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
@ -4284,6 +4377,192 @@ impl Config {
|
||||
)?;
|
||||
}
|
||||
|
||||
// Decrypt channel secrets
|
||||
if let Some(ref mut tg) = config.channels_config.telegram {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut tg.bot_token,
|
||||
"config.channels_config.telegram.bot_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut dc) = config.channels_config.discord {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut dc.bot_token,
|
||||
"config.channels_config.discord.bot_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut sl) = config.channels_config.slack {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut sl.bot_token,
|
||||
"config.channels_config.slack.bot_token",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut sl.app_token,
|
||||
"config.channels_config.slack.app_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut mm) = config.channels_config.mattermost {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut mm.bot_token,
|
||||
"config.channels_config.mattermost.bot_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut mx) = config.channels_config.matrix {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut mx.access_token,
|
||||
"config.channels_config.matrix.access_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut wa) = config.channels_config.whatsapp {
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut wa.access_token,
|
||||
"config.channels_config.whatsapp.access_token",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut wa.app_secret,
|
||||
"config.channels_config.whatsapp.app_secret",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut wa.verify_token,
|
||||
"config.channels_config.whatsapp.verify_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut lq) = config.channels_config.linq {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut lq.api_token,
|
||||
"config.channels_config.linq.api_token",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut lq.signing_secret,
|
||||
"config.channels_config.linq.signing_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut wt) = config.channels_config.wati {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut wt.api_token,
|
||||
"config.channels_config.wati.api_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut nc) = config.channels_config.nextcloud_talk {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut nc.app_token,
|
||||
"config.channels_config.nextcloud_talk.app_token",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut nc.webhook_secret,
|
||||
"config.channels_config.nextcloud_talk.webhook_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut em) = config.channels_config.email {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut em.password,
|
||||
"config.channels_config.email.password",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut irc) = config.channels_config.irc {
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut irc.server_password,
|
||||
"config.channels_config.irc.server_password",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut irc.nickserv_password,
|
||||
"config.channels_config.irc.nickserv_password",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut irc.sasl_password,
|
||||
"config.channels_config.irc.sasl_password",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut lk) = config.channels_config.lark {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut lk.app_secret,
|
||||
"config.channels_config.lark.app_secret",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut lk.encrypt_key,
|
||||
"config.channels_config.lark.encrypt_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut lk.verification_token,
|
||||
"config.channels_config.lark.verification_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut fs) = config.channels_config.feishu {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut fs.app_secret,
|
||||
"config.channels_config.feishu.app_secret",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut fs.encrypt_key,
|
||||
"config.channels_config.feishu.encrypt_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut fs.verification_token,
|
||||
"config.channels_config.feishu.verification_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut dt) = config.channels_config.dingtalk {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut dt.client_secret,
|
||||
"config.channels_config.dingtalk.client_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut qq) = config.channels_config.qq {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut qq.app_secret,
|
||||
"config.channels_config.qq.app_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut wh) = config.channels_config.webhook {
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut wh.secret,
|
||||
"config.channels_config.webhook.secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut ct) = config.channels_config.clawdtalk {
|
||||
decrypt_secret(
|
||||
&store,
|
||||
&mut ct.api_key,
|
||||
"config.channels_config.clawdtalk.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut ct.webhook_secret,
|
||||
"config.channels_config.clawdtalk.webhook_secret",
|
||||
)?;
|
||||
}
|
||||
|
||||
// Decrypt gateway paired tokens
|
||||
for token in &mut config.gateway.paired_tokens {
|
||||
decrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
|
||||
}
|
||||
|
||||
config.apply_env_overrides();
|
||||
config.validate()?;
|
||||
tracing::info!(
|
||||
@ -4628,6 +4907,15 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
// Provider HTTP timeout: ZEROCLAW_PROVIDER_TIMEOUT_SECS
|
||||
if let Ok(timeout_secs) = std::env::var("ZEROCLAW_PROVIDER_TIMEOUT_SECS") {
|
||||
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
|
||||
if timeout_secs > 0 {
|
||||
self.provider_timeout_secs = timeout_secs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply named provider profile remapping (Codex app-server compatibility).
|
||||
self.apply_named_model_provider_profile();
|
||||
|
||||
@ -4926,6 +5214,7 @@ impl Config {
|
||||
encrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
|
||||
}
|
||||
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
if let Some(ref mut ns) = config_to_save.channels_config.nostr {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
@ -4934,6 +5223,192 @@ impl Config {
|
||||
)?;
|
||||
}
|
||||
|
||||
// Encrypt channel secrets
|
||||
if let Some(ref mut tg) = config_to_save.channels_config.telegram {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut tg.bot_token,
|
||||
"config.channels_config.telegram.bot_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut dc) = config_to_save.channels_config.discord {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut dc.bot_token,
|
||||
"config.channels_config.discord.bot_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut sl) = config_to_save.channels_config.slack {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut sl.bot_token,
|
||||
"config.channels_config.slack.bot_token",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut sl.app_token,
|
||||
"config.channels_config.slack.app_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut mm) = config_to_save.channels_config.mattermost {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut mm.bot_token,
|
||||
"config.channels_config.mattermost.bot_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut mx) = config_to_save.channels_config.matrix {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut mx.access_token,
|
||||
"config.channels_config.matrix.access_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut wa) = config_to_save.channels_config.whatsapp {
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut wa.access_token,
|
||||
"config.channels_config.whatsapp.access_token",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut wa.app_secret,
|
||||
"config.channels_config.whatsapp.app_secret",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut wa.verify_token,
|
||||
"config.channels_config.whatsapp.verify_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut lq) = config_to_save.channels_config.linq {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut lq.api_token,
|
||||
"config.channels_config.linq.api_token",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut lq.signing_secret,
|
||||
"config.channels_config.linq.signing_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut wt) = config_to_save.channels_config.wati {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut wt.api_token,
|
||||
"config.channels_config.wati.api_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut nc) = config_to_save.channels_config.nextcloud_talk {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut nc.app_token,
|
||||
"config.channels_config.nextcloud_talk.app_token",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut nc.webhook_secret,
|
||||
"config.channels_config.nextcloud_talk.webhook_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut em) = config_to_save.channels_config.email {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut em.password,
|
||||
"config.channels_config.email.password",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut irc) = config_to_save.channels_config.irc {
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut irc.server_password,
|
||||
"config.channels_config.irc.server_password",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut irc.nickserv_password,
|
||||
"config.channels_config.irc.nickserv_password",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut irc.sasl_password,
|
||||
"config.channels_config.irc.sasl_password",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut lk) = config_to_save.channels_config.lark {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut lk.app_secret,
|
||||
"config.channels_config.lark.app_secret",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut lk.encrypt_key,
|
||||
"config.channels_config.lark.encrypt_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut lk.verification_token,
|
||||
"config.channels_config.lark.verification_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut fs) = config_to_save.channels_config.feishu {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut fs.app_secret,
|
||||
"config.channels_config.feishu.app_secret",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut fs.encrypt_key,
|
||||
"config.channels_config.feishu.encrypt_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut fs.verification_token,
|
||||
"config.channels_config.feishu.verification_token",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut dt) = config_to_save.channels_config.dingtalk {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut dt.client_secret,
|
||||
"config.channels_config.dingtalk.client_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut qq) = config_to_save.channels_config.qq {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut qq.app_secret,
|
||||
"config.channels_config.qq.app_secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut wh) = config_to_save.channels_config.webhook {
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut wh.secret,
|
||||
"config.channels_config.webhook.secret",
|
||||
)?;
|
||||
}
|
||||
if let Some(ref mut ct) = config_to_save.channels_config.clawdtalk {
|
||||
encrypt_secret(
|
||||
&store,
|
||||
&mut ct.api_key,
|
||||
"config.channels_config.clawdtalk.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut ct.webhook_secret,
|
||||
"config.channels_config.clawdtalk.webhook_secret",
|
||||
)?;
|
||||
}
|
||||
|
||||
// Encrypt gateway paired tokens
|
||||
for token in &mut config_to_save.gateway.paired_tokens {
|
||||
encrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
|
||||
}
|
||||
|
||||
let toml_str =
|
||||
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
|
||||
|
||||
@ -5078,6 +5553,7 @@ mod tests {
|
||||
c.skills.prompt_injection_mode,
|
||||
SkillsPromptInjectionMode::Full
|
||||
);
|
||||
assert_eq!(c.provider_timeout_secs, 120);
|
||||
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
|
||||
assert!(c.config_path.to_string_lossy().contains("config.toml"));
|
||||
}
|
||||
@ -5282,6 +5758,7 @@ default_temperature = 0.7
|
||||
default_model: Some("gpt-4o".into()),
|
||||
model_providers: HashMap::new(),
|
||||
default_temperature: 0.5,
|
||||
provider_timeout_secs: 120,
|
||||
observability: ObservabilityConfig {
|
||||
backend: "log".into(),
|
||||
..ObservabilityConfig::default()
|
||||
@ -5347,6 +5824,7 @@ default_temperature = 0.7
|
||||
feishu: None,
|
||||
dingtalk: None,
|
||||
qq: None,
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
nostr: None,
|
||||
clawdtalk: None,
|
||||
message_timeout_secs: 300,
|
||||
@ -5421,6 +5899,18 @@ default_temperature = 0.7
|
||||
assert_eq!(parsed.memory.archive_after_days, 7);
|
||||
assert_eq!(parsed.memory.purge_after_days, 30);
|
||||
assert_eq!(parsed.memory.conversation_retention_days, 30);
|
||||
// provider_timeout_secs defaults to 120 when not specified
|
||||
assert_eq!(parsed.provider_timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn provider_timeout_secs_parses_from_toml() {
|
||||
let raw = r#"
|
||||
default_temperature = 0.7
|
||||
provider_timeout_secs = 300
|
||||
"#;
|
||||
let parsed: Config = toml::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.provider_timeout_secs, 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -5521,6 +6011,7 @@ tool_dispatcher = "xml"
|
||||
default_model: Some("test-model".into()),
|
||||
model_providers: HashMap::new(),
|
||||
default_temperature: 0.9,
|
||||
provider_timeout_secs: 120,
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
security: SecurityConfig::default(),
|
||||
@ -6702,6 +7193,9 @@ requires_openai_auth = true
|
||||
base_url: Some("https://api.tonsof.blue/v1".to_string()),
|
||||
wire_api: None,
|
||||
requires_openai_auth: false,
|
||||
azure_openai_resource: None,
|
||||
azure_openai_deployment: None,
|
||||
azure_openai_api_version: None,
|
||||
},
|
||||
)]),
|
||||
..Config::default()
|
||||
@ -6730,6 +7224,9 @@ requires_openai_auth = true
|
||||
base_url: Some("https://api.tonsof.blue".to_string()),
|
||||
wire_api: Some("responses".to_string()),
|
||||
requires_openai_auth: true,
|
||||
azure_openai_resource: None,
|
||||
azure_openai_deployment: None,
|
||||
azure_openai_api_version: None,
|
||||
},
|
||||
)]),
|
||||
api_key: None,
|
||||
@ -6792,6 +7289,9 @@ requires_openai_auth = true
|
||||
base_url: Some("https://api.tonsof.blue/v1".to_string()),
|
||||
wire_api: Some("ws".to_string()),
|
||||
requires_openai_auth: false,
|
||||
azure_openai_resource: None,
|
||||
azure_openai_deployment: None,
|
||||
azure_openai_api_version: None,
|
||||
},
|
||||
)]),
|
||||
..Config::default()
|
||||
@ -7835,6 +8335,73 @@ require_otp_to_resume = true
|
||||
assert!(err.to_string().contains("gated_domains"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_secret_telegram_bot_token_roundtrip() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"zeroclaw_test_tg_bot_token_{}",
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&dir).await.unwrap();
|
||||
|
||||
let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
|
||||
|
||||
let mut config = Config::default();
|
||||
config.workspace_dir = dir.join("workspace");
|
||||
config.config_path = dir.join("config.toml");
|
||||
config.channels_config.telegram = Some(TelegramConfig {
|
||||
bot_token: plaintext_token.into(),
|
||||
allowed_users: vec!["user1".into()],
|
||||
stream_mode: StreamMode::default(),
|
||||
draft_update_interval_ms: default_draft_update_interval_ms(),
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
});
|
||||
|
||||
// Save (triggers encryption)
|
||||
config.save().await.unwrap();
|
||||
|
||||
// Read raw TOML and verify plaintext token is NOT present
|
||||
let raw_toml = tokio::fs::read_to_string(&config.config_path)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!raw_toml.contains(plaintext_token),
|
||||
"Saved TOML must not contain the plaintext bot_token"
|
||||
);
|
||||
|
||||
// Parse stored TOML and verify the value is encrypted
|
||||
let stored: Config = toml::from_str(&raw_toml).unwrap();
|
||||
let stored_token = &stored.channels_config.telegram.as_ref().unwrap().bot_token;
|
||||
assert!(
|
||||
crate::security::SecretStore::is_encrypted(stored_token),
|
||||
"Stored bot_token must be marked as encrypted"
|
||||
);
|
||||
|
||||
// Decrypt and verify it matches the original plaintext
|
||||
let store = crate::security::SecretStore::new(&dir, true);
|
||||
assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
|
||||
|
||||
// Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
|
||||
let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
|
||||
loaded.config_path = dir.join("config.toml");
|
||||
let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
|
||||
if let Some(ref mut tg) = loaded.channels_config.telegram {
|
||||
decrypt_secret(
|
||||
&load_store,
|
||||
&mut tg.bot_token,
|
||||
"config.channels_config.telegram.bot_token",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
loaded.channels_config.telegram.as_ref().unwrap().bot_token,
|
||||
plaintext_token,
|
||||
"Loaded bot_token must match the original plaintext after decryption"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
async fn security_validation_rejects_unknown_domain_category() {
|
||||
let mut config = Config::default();
|
||||
|
||||
309
src/cron/mod.rs
309
src/cron/mod.rs
@ -1,6 +1,6 @@
|
||||
use crate::config::Config;
|
||||
use crate::security::SecurityPolicy;
|
||||
use anyhow::{bail, Result};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
|
||||
mod schedule;
|
||||
mod store;
|
||||
@ -14,11 +14,106 @@ pub use schedule::{
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use store::{
|
||||
add_agent_job, add_job, add_shell_job, due_jobs, get_job, list_jobs, list_runs,
|
||||
record_last_run, record_run, remove_job, reschedule_after_run, update_job,
|
||||
add_agent_job, due_jobs, get_job, list_jobs, list_runs, record_last_run, record_run,
|
||||
remove_job, reschedule_after_run, update_job,
|
||||
};
|
||||
pub use types::{CronJob, CronJobPatch, CronRun, DeliveryConfig, JobType, Schedule, SessionTarget};
|
||||
|
||||
/// Validate a shell command against the full security policy (allowlist + risk gate).
|
||||
///
|
||||
/// Returns `Ok(())` if the command passes all checks, or an error describing
|
||||
/// why it was blocked.
|
||||
pub fn validate_shell_command(config: &Config, command: &str, approved: bool) -> Result<()> {
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
validate_shell_command_with_security(&security, command, approved)
|
||||
}
|
||||
|
||||
/// Validate a shell command using an existing `SecurityPolicy` instance.
|
||||
///
|
||||
/// Preferred when the caller already holds a `SecurityPolicy` (e.g. scheduler).
|
||||
pub(crate) fn validate_shell_command_with_security(
|
||||
security: &SecurityPolicy,
|
||||
command: &str,
|
||||
approved: bool,
|
||||
) -> Result<()> {
|
||||
security
|
||||
.validate_command_execution(command, approved)
|
||||
.map(|_| ())
|
||||
.map_err(|reason| anyhow!("blocked by security policy: {reason}"))
|
||||
}
|
||||
|
||||
/// Create a validated shell job, enforcing security policy before persistence.
|
||||
///
|
||||
/// All entrypoints that create shell cron jobs should route through this
|
||||
/// function to guarantee consistent policy enforcement.
|
||||
pub fn add_shell_job_with_approval(
|
||||
config: &Config,
|
||||
name: Option<String>,
|
||||
schedule: Schedule,
|
||||
command: &str,
|
||||
approved: bool,
|
||||
) -> Result<CronJob> {
|
||||
validate_shell_command(config, command, approved)?;
|
||||
store::add_shell_job(config, name, schedule, command)
|
||||
}
|
||||
|
||||
/// Update a shell job's command with security validation.
|
||||
///
|
||||
/// Validates the new command (if changed) before persisting.
|
||||
pub fn update_shell_job_with_approval(
|
||||
config: &Config,
|
||||
job_id: &str,
|
||||
patch: CronJobPatch,
|
||||
approved: bool,
|
||||
) -> Result<CronJob> {
|
||||
if let Some(command) = patch.command.as_deref() {
|
||||
validate_shell_command(config, command, approved)?;
|
||||
}
|
||||
update_job(config, job_id, patch)
|
||||
}
|
||||
|
||||
/// Create a one-shot validated shell job from a delay string (e.g. "30m").
|
||||
pub fn add_once_validated(
|
||||
config: &Config,
|
||||
delay: &str,
|
||||
command: &str,
|
||||
approved: bool,
|
||||
) -> Result<CronJob> {
|
||||
let duration = parse_delay(delay)?;
|
||||
let at = chrono::Utc::now() + duration;
|
||||
add_once_at_validated(config, at, command, approved)
|
||||
}
|
||||
|
||||
/// Create a one-shot validated shell job at an absolute timestamp.
|
||||
pub fn add_once_at_validated(
|
||||
config: &Config,
|
||||
at: chrono::DateTime<chrono::Utc>,
|
||||
command: &str,
|
||||
approved: bool,
|
||||
) -> Result<CronJob> {
|
||||
let schedule = Schedule::At { at };
|
||||
add_shell_job_with_approval(config, None, schedule, command, approved)
|
||||
}
|
||||
|
||||
// Convenience wrappers for CLI paths (default approved=false).
|
||||
|
||||
pub(crate) fn add_shell_job(
|
||||
config: &Config,
|
||||
name: Option<String>,
|
||||
schedule: Schedule,
|
||||
command: &str,
|
||||
) -> Result<CronJob> {
|
||||
add_shell_job_with_approval(config, name, schedule, command, false)
|
||||
}
|
||||
|
||||
pub(crate) fn add_job(config: &Config, expression: &str, command: &str) -> Result<CronJob> {
|
||||
let schedule = Schedule::Cron {
|
||||
expr: expression.to_string(),
|
||||
tz: None,
|
||||
};
|
||||
add_shell_job(config, None, schedule, command)
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<()> {
|
||||
match command {
|
||||
@ -128,13 +223,6 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref cmd) = command {
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
if !security.is_command_allowed(cmd) {
|
||||
bail!("Command blocked by security policy: {cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
let patch = CronJobPatch {
|
||||
schedule,
|
||||
command,
|
||||
@ -142,7 +230,7 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
..CronJobPatch::default()
|
||||
};
|
||||
|
||||
let job = update_job(config, &id, patch)?;
|
||||
let job = update_shell_job_with_approval(config, &id, patch, false)?;
|
||||
println!("\u{2705} Updated cron job {}", job.id);
|
||||
println!(" Expr: {}", job.expression);
|
||||
println!(" Next: {}", job.next_run.to_rfc3339());
|
||||
@ -163,19 +251,16 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_once(config: &Config, delay: &str, command: &str) -> Result<CronJob> {
|
||||
let duration = parse_delay(delay)?;
|
||||
let at = chrono::Utc::now() + duration;
|
||||
add_once_at(config, at, command)
|
||||
pub(crate) fn add_once(config: &Config, delay: &str, command: &str) -> Result<CronJob> {
|
||||
add_once_validated(config, delay, command, false)
|
||||
}
|
||||
|
||||
pub fn add_once_at(
|
||||
pub(crate) fn add_once_at(
|
||||
config: &Config,
|
||||
at: chrono::DateTime<chrono::Utc>,
|
||||
command: &str,
|
||||
) -> Result<CronJob> {
|
||||
let schedule = Schedule::At { at };
|
||||
add_shell_job(config, None, schedule, command)
|
||||
add_once_at_validated(config, at, command, false)
|
||||
}
|
||||
|
||||
pub fn pause_job(config: &Config, id: &str) -> Result<CronJob> {
|
||||
@ -413,4 +498,192 @@ mod tests {
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
assert!(security.is_command_allowed("echo safe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_shell_job_requires_explicit_approval_for_medium_risk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
|
||||
|
||||
let denied = add_shell_job(
|
||||
&config,
|
||||
None,
|
||||
Schedule::Cron {
|
||||
expr: "*/5 * * * *".into(),
|
||||
tz: None,
|
||||
},
|
||||
"touch cron-medium-risk",
|
||||
);
|
||||
assert!(denied.is_err());
|
||||
assert!(denied
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("explicit approval"));
|
||||
|
||||
let approved = add_shell_job_with_approval(
|
||||
&config,
|
||||
None,
|
||||
Schedule::Cron {
|
||||
expr: "*/5 * * * *".into(),
|
||||
tz: None,
|
||||
},
|
||||
"touch cron-medium-risk",
|
||||
true,
|
||||
);
|
||||
assert!(approved.is_ok(), "{approved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_requires_explicit_approval_for_medium_risk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
|
||||
let job = make_job(&config, "*/5 * * * *", None, "echo original");
|
||||
|
||||
let denied = update_shell_job_with_approval(
|
||||
&config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
command: Some("touch cron-medium-risk-update".into()),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
false,
|
||||
);
|
||||
assert!(denied.is_err());
|
||||
assert!(denied
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("explicit approval"));
|
||||
|
||||
let approved = update_shell_job_with_approval(
|
||||
&config,
|
||||
&job.id,
|
||||
CronJobPatch {
|
||||
command: Some("touch cron-medium-risk-update".into()),
|
||||
..CronJobPatch::default()
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(approved.command, "touch cron-medium-risk-update");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_update_requires_explicit_approval_for_medium_risk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
|
||||
let job = make_job(&config, "*/5 * * * *", None, "echo original");
|
||||
|
||||
let result = run_update(
|
||||
&config,
|
||||
&job.id,
|
||||
None,
|
||||
None,
|
||||
Some("touch cron-cli-medium-risk"),
|
||||
None,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("explicit approval"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_once_validated_creates_one_shot_job() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
|
||||
let job = add_once_validated(&config, "1h", "echo one-shot", false).unwrap();
|
||||
assert_eq!(job.command, "echo one-shot");
|
||||
assert!(matches!(job.schedule, Schedule::At { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_once_validated_blocks_disallowed_command() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into()];
|
||||
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
|
||||
|
||||
let result = add_once_validated(&config, "1h", "curl https://example.com", false);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("blocked by security policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_once_at_validated_creates_one_shot_job() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let config = test_config(&tmp);
|
||||
let at = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let job = add_once_at_validated(&config, at, "echo at-shot", false).unwrap();
|
||||
assert_eq!(job.command, "echo at-shot");
|
||||
assert!(matches!(job.schedule, Schedule::At { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_once_at_validated_blocks_medium_risk_without_approval() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into(), "touch".into()];
|
||||
let at = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
|
||||
let denied = add_once_at_validated(&config, at, "touch at-medium", false);
|
||||
assert!(denied.is_err());
|
||||
assert!(denied
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("explicit approval"));
|
||||
|
||||
let approved = add_once_at_validated(&config, at, "touch at-medium", true);
|
||||
assert!(approved.is_ok(), "{approved:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_api_path_validates_shell_command() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into()];
|
||||
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
|
||||
|
||||
// Simulate gateway API path: add_shell_job_with_approval(approved=false)
|
||||
let result = add_shell_job_with_approval(
|
||||
&config,
|
||||
None,
|
||||
Schedule::Cron {
|
||||
expr: "*/5 * * * *".into(),
|
||||
tz: None,
|
||||
},
|
||||
"curl https://example.com",
|
||||
false,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("blocked by security policy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduler_path_validates_shell_command() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut config = test_config(&tmp);
|
||||
config.autonomy.allowed_commands = vec!["echo".into()];
|
||||
config.autonomy.level = crate::security::AutonomyLevel::Supervised;
|
||||
|
||||
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
|
||||
// Simulate scheduler validation path
|
||||
let result =
|
||||
validate_shell_command_with_security(&security, "curl https://example.com", false);
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("blocked by security policy"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -406,14 +406,15 @@ async fn run_job_command_with_timeout(
|
||||
);
|
||||
}
|
||||
|
||||
if !security.is_command_allowed(&job.command) {
|
||||
return (
|
||||
false,
|
||||
format!(
|
||||
"blocked by security policy: command not allowed: {}",
|
||||
job.command
|
||||
),
|
||||
);
|
||||
// Unified command validation: allowlist + risk + path checks in one call.
|
||||
// Jobs created via the validated helpers were already checked at creation
|
||||
// time, but we re-validate at execution time to catch policy changes and
|
||||
// manually-edited job stores.
|
||||
let approved = false; // scheduler runs are never pre-approved
|
||||
if let Err(error) =
|
||||
crate::cron::validate_shell_command_with_security(security, &job.command, approved)
|
||||
{
|
||||
return (false, error.to_string());
|
||||
}
|
||||
|
||||
if let Some(path) = security.forbidden_path_argument(&job.command) {
|
||||
@ -565,7 +566,7 @@ mod tests {
|
||||
let (success, output) = run_job_command(&config, &security, &job).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("blocked by security policy"));
|
||||
assert!(output.contains("command not allowed"));
|
||||
assert!(output.to_lowercase().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -639,7 +640,7 @@ mod tests {
|
||||
let (success, output) = run_job_command(&config, &security, &job).await;
|
||||
assert!(!success);
|
||||
assert!(output.contains("blocked by security policy"));
|
||||
assert!(output.contains("command not allowed"));
|
||||
assert!(output.to_lowercase().contains("not allowed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -8,6 +8,34 @@ use tokio::time::Duration;
|
||||
|
||||
const STATUS_FLUSH_SECONDS: u64 = 5;
|
||||
|
||||
/// Wait for shutdown signal (SIGINT or SIGTERM)
|
||||
async fn wait_for_shutdown_signal() -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
|
||||
let mut sigint = signal(SignalKind::interrupt())?;
|
||||
let mut sigterm = signal(SignalKind::terminate())?;
|
||||
|
||||
tokio::select! {
|
||||
_ = sigint.recv() => {
|
||||
tracing::info!("Received SIGINT, shutting down...");
|
||||
}
|
||||
_ = sigterm.recv() => {
|
||||
tracing::info!("Received SIGTERM, shutting down...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::signal::ctrl_c().await?;
|
||||
tracing::info!("Received Ctrl+C, shutting down...");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(config: Config, host: String, port: u16) -> Result<()> {
|
||||
let initial_backoff = config.reliability.channel_initial_backoff_secs.max(1);
|
||||
let max_backoff = config
|
||||
@ -90,9 +118,10 @@ pub async fn run(config: Config, host: String, port: u16) -> Result<()> {
|
||||
println!("🧠 ZeroClaw daemon started");
|
||||
println!(" Gateway: http://{host}:{port}");
|
||||
println!(" Components: gateway, channels, heartbeat, scheduler");
|
||||
println!(" Ctrl+C to stop");
|
||||
println!(" Ctrl+C or SIGTERM to stop");
|
||||
|
||||
tokio::signal::ctrl_c().await?;
|
||||
// Wait for shutdown signal (SIGINT or SIGTERM)
|
||||
wait_for_shutdown_signal().await?;
|
||||
crate::health::mark_component_error("daemon", "shutdown requested");
|
||||
|
||||
for handle in &handles {
|
||||
|
||||
@ -257,7 +257,13 @@ pub async fn handle_api_cron_add(
|
||||
tz: None,
|
||||
};
|
||||
|
||||
match crate::cron::add_shell_job(&config, body.name, schedule, &body.command) {
|
||||
match crate::cron::add_shell_job_with_approval(
|
||||
&config,
|
||||
body.name,
|
||||
schedule,
|
||||
&body.command,
|
||||
false,
|
||||
) {
|
||||
Ok(job) => Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"job": {
|
||||
@ -325,6 +331,35 @@ pub async fn handle_api_integrations(
|
||||
Json(serde_json::json!({"integrations": integrations})).into_response()
|
||||
}
|
||||
|
||||
/// GET /api/integrations/settings — return per-integration settings (enabled + category)
|
||||
pub async fn handle_api_integrations_settings(
|
||||
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();
|
||||
let entries = crate::integrations::registry::all_integrations();
|
||||
|
||||
let mut settings = serde_json::Map::new();
|
||||
for entry in &entries {
|
||||
let status = (entry.status_fn)(&config);
|
||||
let enabled = matches!(status, crate::integrations::IntegrationStatus::Active);
|
||||
settings.insert(
|
||||
entry.name.to_string(),
|
||||
serde_json::json!({
|
||||
"enabled": enabled,
|
||||
"category": entry.category,
|
||||
"status": status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Json(serde_json::json!({"settings": settings})).into_response()
|
||||
}
|
||||
|
||||
/// POST /api/doctor — run diagnostics
|
||||
pub async fn handle_api_doctor(
|
||||
State(state): State<AppState>,
|
||||
@ -776,6 +811,7 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi
|
||||
if let Some(qq) = masked.channels_config.qq.as_mut() {
|
||||
mask_required_secret(&mut qq.app_secret);
|
||||
}
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
if let Some(nostr) = masked.channels_config.nostr.as_mut() {
|
||||
mask_required_secret(&mut nostr.private_key);
|
||||
}
|
||||
@ -953,6 +989,7 @@ fn restore_masked_sensitive_fields(
|
||||
) {
|
||||
restore_required_secret(&mut incoming_ch.app_secret, ¤t_ch.app_secret);
|
||||
}
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
if let (Some(incoming_ch), Some(current_ch)) = (
|
||||
incoming.channels_config.nostr.as_mut(),
|
||||
current.channels_config.nostr.as_ref(),
|
||||
@ -1026,6 +1063,7 @@ mod tests {
|
||||
from_address: "agent@example.com".to_string(),
|
||||
idle_timeout_secs: 1740,
|
||||
allowed_senders: vec!["*".to_string()],
|
||||
default_subject: "ZeroClaw Message".to_string(),
|
||||
});
|
||||
cfg.model_routes = vec![crate::config::schema::ModelRouteConfig {
|
||||
hint: "reasoning".to_string(),
|
||||
@ -1159,6 +1197,7 @@ mod tests {
|
||||
from_address: "agent@example.com".to_string(),
|
||||
idle_timeout_secs: 1740,
|
||||
allowed_senders: vec!["*".to_string()],
|
||||
default_subject: "ZeroClaw Message".to_string(),
|
||||
});
|
||||
current.model_routes = vec![
|
||||
crate::config::schema::ModelRouteConfig {
|
||||
|
||||
@ -351,6 +351,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
zeroclaw_dir: config.config_path.parent().map(std::path::PathBuf::from),
|
||||
secrets_encrypt: config.secrets.encrypt,
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(config.provider_timeout_secs),
|
||||
},
|
||||
)?);
|
||||
let model = config
|
||||
@ -682,6 +683,10 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/api/cron", post(api::handle_api_cron_add))
|
||||
.route("/api/cron/{id}", delete(api::handle_api_cron_delete))
|
||||
.route("/api/integrations", get(api::handle_api_integrations))
|
||||
.route(
|
||||
"/api/integrations/settings",
|
||||
get(api::handle_api_integrations_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/doctor",
|
||||
get(api::handle_api_doctor).post(api::handle_api_doctor),
|
||||
@ -1933,16 +1938,24 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let saved = tokio::fs::read_to_string(config_path).await.unwrap();
|
||||
let parsed: Config = toml::from_str(&saved).unwrap();
|
||||
assert_eq!(parsed.gateway.paired_tokens.len(), 1);
|
||||
let persisted = &parsed.gateway.paired_tokens[0];
|
||||
assert_eq!(persisted.len(), 64);
|
||||
assert!(persisted.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
// In-memory tokens should remain as plaintext 64-char hex hashes.
|
||||
let plaintext = {
|
||||
let in_memory = shared_config.lock();
|
||||
assert_eq!(in_memory.gateway.paired_tokens.len(), 1);
|
||||
in_memory.gateway.paired_tokens[0].clone()
|
||||
};
|
||||
assert_eq!(plaintext.len(), 64);
|
||||
assert!(plaintext.chars().all(|c: char| c.is_ascii_hexdigit()));
|
||||
|
||||
let in_memory = shared_config.lock();
|
||||
assert_eq!(in_memory.gateway.paired_tokens.len(), 1);
|
||||
assert_eq!(&in_memory.gateway.paired_tokens[0], persisted);
|
||||
// On disk, the token should be encrypted (secrets.encrypt defaults to true).
|
||||
let saved = tokio::fs::read_to_string(config_path).await.unwrap();
|
||||
let raw_parsed: Config = toml::from_str(&saved).unwrap();
|
||||
assert_eq!(raw_parsed.gateway.paired_tokens.len(), 1);
|
||||
let on_disk = &raw_parsed.gateway.paired_tokens[0];
|
||||
assert!(
|
||||
crate::security::SecretStore::is_encrypted(on_disk),
|
||||
"paired_token should be encrypted on disk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -98,7 +98,7 @@ impl crate::observability::Observer for BroadcastObserver {
|
||||
"success": success,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
}),
|
||||
crate::observability::ObserverEvent::ToolCallStart { tool } => serde_json::json!({
|
||||
crate::observability::ObserverEvent::ToolCallStart { tool, .. } => serde_json::json!({
|
||||
"type": "tool_call_start",
|
||||
"tool": tool,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
|
||||
@ -15,39 +15,108 @@ use axum::{
|
||||
ws::{Message, WebSocket},
|
||||
Query, State, WebSocketUpgrade,
|
||||
},
|
||||
http::{header, HeaderMap},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The sub-protocol we support for the chat WebSocket.
|
||||
const WS_PROTOCOL: &str = "zeroclaw.v1";
|
||||
|
||||
/// Prefix used in `Sec-WebSocket-Protocol` to carry a bearer token.
|
||||
const BEARER_SUBPROTO_PREFIX: &str = "bearer.";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WsQuery {
|
||||
pub token: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Extract a bearer token from WebSocket-compatible sources.
|
||||
///
|
||||
/// Precedence (first non-empty wins):
|
||||
/// 1. `Authorization: Bearer <token>` header
|
||||
/// 2. `Sec-WebSocket-Protocol: bearer.<token>` subprotocol
|
||||
/// 3. `?token=<token>` query parameter
|
||||
///
|
||||
/// Browsers cannot set custom headers on `new WebSocket(url)`, so the query
|
||||
/// parameter and subprotocol paths are required for browser-based clients.
|
||||
fn extract_ws_token<'a>(headers: &'a HeaderMap, query_token: Option<&'a str>) -> Option<&'a str> {
|
||||
// 1. Authorization header
|
||||
if let Some(t) = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|auth| auth.strip_prefix("Bearer "))
|
||||
{
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sec-WebSocket-Protocol: bearer.<token>
|
||||
if let Some(t) = headers
|
||||
.get("sec-websocket-protocol")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|protos| {
|
||||
protos
|
||||
.split(',')
|
||||
.map(|p| p.trim())
|
||||
.find_map(|p| p.strip_prefix(BEARER_SUBPROTO_PREFIX))
|
||||
})
|
||||
{
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. ?token= query parameter
|
||||
if let Some(t) = query_token {
|
||||
if !t.is_empty() {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// GET /ws/chat — WebSocket upgrade for agent chat
|
||||
pub async fn handle_ws_chat(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<WsQuery>,
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
// Auth via query param (browser WebSocket limitation)
|
||||
// Auth: check header, subprotocol, then query param (precedence order)
|
||||
if state.pairing.require_pairing() {
|
||||
let token = params.token.as_deref().unwrap_or("");
|
||||
let token = extract_ws_token(&headers, params.token.as_deref()).unwrap_or("");
|
||||
if !state.pairing.is_authenticated(token) {
|
||||
return (
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"Unauthorized — provide ?token=<bearer_token>",
|
||||
"Unauthorized — provide Authorization header, Sec-WebSocket-Protocol bearer, or ?token= query param",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
// Echo Sec-WebSocket-Protocol if the client requests our sub-protocol.
|
||||
let ws = if headers
|
||||
.get("sec-websocket-protocol")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map_or(false, |protos| {
|
||||
protos.split(',').any(|p| p.trim() == WS_PROTOCOL)
|
||||
}) {
|
||||
ws.protocols([WS_PROTOCOL])
|
||||
} else {
|
||||
ws
|
||||
};
|
||||
|
||||
let session_id = params.session_id.clone();
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, session_id))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
async fn handle_socket(socket: WebSocket, state: AppState, _session_id: Option<String>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
while let Some(msg) = receiver.next().await {
|
||||
@ -164,3 +233,85 @@ async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_from_authorization_header() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer zc_test123".parse().unwrap());
|
||||
assert_eq!(extract_ws_token(&headers, None), Some("zc_test123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_from_subprotocol() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"sec-websocket-protocol",
|
||||
"zeroclaw.v1, bearer.zc_sub456".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(extract_ws_token(&headers, None), Some("zc_sub456"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_from_query_param() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(
|
||||
extract_ws_token(&headers, Some("zc_query789")),
|
||||
Some("zc_query789")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_precedence_header_over_subprotocol() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer zc_header".parse().unwrap());
|
||||
headers.insert("sec-websocket-protocol", "bearer.zc_sub".parse().unwrap());
|
||||
assert_eq!(
|
||||
extract_ws_token(&headers, Some("zc_query")),
|
||||
Some("zc_header")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_precedence_subprotocol_over_query() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("sec-websocket-protocol", "bearer.zc_sub".parse().unwrap());
|
||||
assert_eq!(extract_ws_token(&headers, Some("zc_query")), Some("zc_sub"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_returns_none_when_empty() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_ws_token(&headers, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_skips_empty_header_value() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", "Bearer ".parse().unwrap());
|
||||
assert_eq!(
|
||||
extract_ws_token(&headers, Some("zc_fallback")),
|
||||
Some("zc_fallback")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_skips_empty_query_param() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_ws_token(&headers, Some("")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_token_subprotocol_with_multiple_entries() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"sec-websocket-protocol",
|
||||
"zeroclaw.v1, bearer.zc_tok, other".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(extract_ws_token(&headers, None), Some("zc_tok"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
pub mod command_logger;
|
||||
pub mod webhook_audit;
|
||||
|
||||
pub use command_logger::CommandLoggerHook;
|
||||
pub use webhook_audit::WebhookAuditHook;
|
||||
|
||||
567
src/hooks/builtin/webhook_audit.rs
Normal file
567
src/hooks/builtin/webhook_audit.rs
Normal file
@ -0,0 +1,567 @@
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::schema::WebhookAuditConfig;
|
||||
use crate::hooks::traits::{HookHandler, HookResult};
|
||||
use crate::tools::traits::ToolResult;
|
||||
|
||||
/// Validate a webhook URL against SSRF attacks.
|
||||
///
|
||||
/// Rejects URLs with:
|
||||
/// - Non-HTTPS schemes (HTTP is allowed for localhost in debug builds only)
|
||||
/// - Loopback addresses (127.0.0.0/8, ::1)
|
||||
/// - Link-local addresses (169.254.0.0/16, fe80::/10)
|
||||
/// - RFC1918 private addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
|
||||
fn validate_webhook_url(url: &str) -> Result<(), String> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|e| format!("invalid webhook URL: {e}"))?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
let host_str = parsed.host_str().unwrap_or("");
|
||||
|
||||
// Scheme check: require https, allow http only for localhost in debug builds.
|
||||
let is_localhost = host_str == "localhost" || host_str == "127.0.0.1" || host_str == "::1";
|
||||
|
||||
if scheme != "https" {
|
||||
if scheme == "http" && is_localhost && cfg!(debug_assertions) {
|
||||
// Allow http://localhost in dev/debug builds.
|
||||
} else {
|
||||
return Err(format!(
|
||||
"webhook URL must use https:// scheme (got {scheme}://)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the host to check for private/loopback/link-local IPs.
|
||||
if let Some(host) = parsed.host_str() {
|
||||
// Strip brackets from IPv6 literals.
|
||||
let bare = host.trim_start_matches('[').trim_end_matches(']');
|
||||
if let Ok(ip) = bare.parse::<IpAddr>() {
|
||||
reject_private_ip(ip)?;
|
||||
} else {
|
||||
// Domain name — check for well-known loopback domains.
|
||||
if bare == "localhost" && !(cfg!(debug_assertions) && scheme == "http") {
|
||||
return Err("webhook URL must not target localhost".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_private_ip(addr: IpAddr) -> Result<(), String> {
|
||||
match addr {
|
||||
IpAddr::V4(ip) => {
|
||||
if ip.is_loopback() {
|
||||
return Err(format!(
|
||||
"webhook URL must not target loopback address ({ip})"
|
||||
));
|
||||
}
|
||||
let octets = ip.octets();
|
||||
// 10.0.0.0/8
|
||||
if octets[0] == 10 {
|
||||
return Err(format!(
|
||||
"webhook URL must not target private address ({ip})"
|
||||
));
|
||||
}
|
||||
// 172.16.0.0/12
|
||||
if octets[0] == 172 && (octets[1] & 0xf0) == 16 {
|
||||
return Err(format!(
|
||||
"webhook URL must not target private address ({ip})"
|
||||
));
|
||||
}
|
||||
// 192.168.0.0/16
|
||||
if octets[0] == 192 && octets[1] == 168 {
|
||||
return Err(format!(
|
||||
"webhook URL must not target private address ({ip})"
|
||||
));
|
||||
}
|
||||
// 169.254.0.0/16 (link-local)
|
||||
if octets[0] == 169 && octets[1] == 254 {
|
||||
return Err(format!(
|
||||
"webhook URL must not target link-local address ({ip})"
|
||||
));
|
||||
}
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if ip.is_loopback() {
|
||||
return Err(format!(
|
||||
"webhook URL must not target loopback address ({ip})"
|
||||
));
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fe80::/10 (link-local)
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return Err(format!(
|
||||
"webhook URL must not target link-local address ({ip})"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends an HTTP POST with a JSON audit payload for matching tool calls.
|
||||
pub struct WebhookAuditHook {
|
||||
config: WebhookAuditConfig,
|
||||
client: reqwest::Client,
|
||||
pending_args: Arc<Mutex<HashMap<String, Vec<Value>>>>,
|
||||
}
|
||||
|
||||
impl WebhookAuditHook {
|
||||
pub fn new(config: WebhookAuditConfig) -> Self {
|
||||
// Warn if enabled but no URL configured.
|
||||
if config.enabled && config.url.is_empty() {
|
||||
tracing::warn!(
|
||||
hook = "webhook-audit",
|
||||
"webhook-audit hook is enabled but no URL is configured — audit events will be dropped"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate URL against SSRF if one is provided.
|
||||
if !config.url.is_empty() {
|
||||
if let Err(e) = validate_webhook_url(&config.url) {
|
||||
tracing::error!(hook = "webhook-audit", error = %e, "webhook URL validation failed");
|
||||
panic!("webhook-audit: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.expect("failed to build webhook HTTP client");
|
||||
Self {
|
||||
config,
|
||||
client,
|
||||
pending_args: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple glob matching: `*` matches any sequence of characters.
|
||||
fn glob_matches(pattern: &str, text: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
if !pattern.contains('*') {
|
||||
return pattern == text;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = pattern.split('*').collect();
|
||||
|
||||
// Edge case: pattern is just "*" (already handled above) or multiple stars
|
||||
let mut pos = 0usize;
|
||||
|
||||
// The first segment must match the beginning of the text (unless pattern starts with *)
|
||||
if !pattern.starts_with('*') {
|
||||
let first = parts[0];
|
||||
if !text.starts_with(first) {
|
||||
return false;
|
||||
}
|
||||
pos = first.len();
|
||||
}
|
||||
|
||||
// The last segment must match the end of the text (unless pattern ends with *)
|
||||
if !pattern.ends_with('*') {
|
||||
let last = parts[parts.len() - 1];
|
||||
if !text.ends_with(last) {
|
||||
return false;
|
||||
}
|
||||
// Ensure no overlap with the prefix we already consumed
|
||||
if text.len() < pos + last.len() {
|
||||
// Check for overlap case: e.g. pattern "ab*b" text "ab"
|
||||
// pos would be 2 (after "ab"), last is "b", text.len()=2, 2 < 2+1=3 -> false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Now check that the middle segments appear in order between pos and
|
||||
// the end boundary.
|
||||
let end_boundary = if pattern.ends_with('*') {
|
||||
text.len()
|
||||
} else {
|
||||
text.len() - parts[parts.len() - 1].len()
|
||||
};
|
||||
|
||||
let start_idx = if pattern.starts_with('*') { 0 } else { 1 };
|
||||
let end_idx = if pattern.ends_with('*') {
|
||||
parts.len()
|
||||
} else {
|
||||
parts.len() - 1
|
||||
};
|
||||
|
||||
for part in &parts[start_idx..end_idx] {
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(found) = text[pos..end_boundary].find(part) {
|
||||
pos += found + part.len();
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns true if `tool` matches any of the given glob patterns.
|
||||
fn matches_any_pattern(patterns: &[String], tool: &str) -> bool {
|
||||
patterns.iter().any(|p| glob_matches(p, tool))
|
||||
}
|
||||
|
||||
/// Truncate serialised args to `max_bytes`. If 0, no truncation.
|
||||
///
|
||||
/// Uses byte-oriented slicing with char-boundary alignment to avoid
|
||||
/// mixing byte length comparisons with char-count truncation.
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
fn truncate_args(args: Value, max_bytes: u64) -> Value {
|
||||
if max_bytes == 0 {
|
||||
return args;
|
||||
}
|
||||
let serialised = match serde_json::to_string(&args) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return args,
|
||||
};
|
||||
if serialised.len() <= max_bytes as usize {
|
||||
args
|
||||
} else {
|
||||
let mut end = max_bytes as usize;
|
||||
while end > 0 && !serialised.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
Value::String(format!("{}...[truncated]", &serialised[..end]))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HookHandler for WebhookAuditHook {
|
||||
fn name(&self) -> &str {
|
||||
"webhook-audit"
|
||||
}
|
||||
|
||||
fn priority(&self) -> i32 {
|
||||
-100
|
||||
}
|
||||
|
||||
async fn before_tool_call(&self, name: String, args: Value) -> HookResult<(String, Value)> {
|
||||
if self.config.include_args && matches_any_pattern(&self.config.tool_patterns, &name) {
|
||||
tracing::debug!(hook = "webhook-audit", tool = %name, "capturing args for audit");
|
||||
self.pending_args
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.entry(name.clone())
|
||||
.or_default()
|
||||
.push(args.clone());
|
||||
}
|
||||
HookResult::Continue((name, args))
|
||||
}
|
||||
|
||||
async fn on_after_tool_call(&self, tool: &str, result: &ToolResult, duration: Duration) {
|
||||
// Skip if no URL configured.
|
||||
if self.config.url.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip tools that don't match the configured patterns.
|
||||
if !matches_any_pattern(&self.config.tool_patterns, tool) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pop the first captured args entry for this tool (FIFO) and optionally truncate.
|
||||
let args_value: Value = if self.config.include_args {
|
||||
let raw = {
|
||||
let mut map = self.pending_args.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = map.get_mut(tool).and_then(|v| {
|
||||
if v.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(v.remove(0))
|
||||
}
|
||||
});
|
||||
// Clean up empty entries.
|
||||
if map.get(tool).is_some_and(|v| v.is_empty()) {
|
||||
map.remove(tool);
|
||||
}
|
||||
entry
|
||||
};
|
||||
match raw {
|
||||
Some(a) => truncate_args(a, self.config.max_args_bytes),
|
||||
None => Value::Null,
|
||||
}
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let duration_ms = duration.as_millis() as u64;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"event": "tool_call",
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"tool": tool,
|
||||
"success": result.success,
|
||||
"duration_ms": duration_ms,
|
||||
"error": result.error,
|
||||
"args": args_value,
|
||||
});
|
||||
|
||||
let client = self.client.clone();
|
||||
let url = self.config.url.clone();
|
||||
|
||||
// Fire-and-forget — never block the agent loop.
|
||||
tokio::spawn(async move {
|
||||
match client.post(&url).json(&payload).send().await {
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
tracing::error!(
|
||||
hook = "webhook-audit",
|
||||
url = %url,
|
||||
status = %resp.status(),
|
||||
"webhook endpoint returned non-success status"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
hook = "webhook-audit",
|
||||
url = %url,
|
||||
error = %e,
|
||||
"failed to POST audit payload"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── Glob matching tests ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn glob_exact_match() {
|
||||
assert!(glob_matches("file_write", "file_write"));
|
||||
assert!(!glob_matches("file_write", "file_read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_wildcard_suffix() {
|
||||
assert!(glob_matches("mcp__*", "mcp__github"));
|
||||
assert!(glob_matches("mcp__*", "mcp__"));
|
||||
assert!(!glob_matches("mcp__*", "mcp_github"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_wildcard_prefix() {
|
||||
assert!(glob_matches("*_write", "file_write"));
|
||||
assert!(glob_matches("*_write", "_write"));
|
||||
assert!(!glob_matches("*_write", "file_read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_wildcard_middle() {
|
||||
assert!(glob_matches("mcp__*__create", "mcp__github__create"));
|
||||
assert!(glob_matches("mcp__*__create", "mcp____create"));
|
||||
assert!(!glob_matches("mcp__*__create", "mcp__github__delete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_star_matches_everything() {
|
||||
assert!(glob_matches("*", "anything_at_all"));
|
||||
assert!(glob_matches("*", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_empty_pattern() {
|
||||
assert!(glob_matches("", ""));
|
||||
assert!(!glob_matches("", "something"));
|
||||
}
|
||||
|
||||
// ── matches_any_pattern ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn matches_any_pattern_works() {
|
||||
let patterns = vec!["Bash".to_string(), "mcp__*".to_string()];
|
||||
assert!(matches_any_pattern(&patterns, "Bash"));
|
||||
assert!(matches_any_pattern(&patterns, "mcp__github"));
|
||||
assert!(!matches_any_pattern(&patterns, "Write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_patterns_matches_nothing() {
|
||||
let patterns: Vec<String> = vec![];
|
||||
assert!(!matches_any_pattern(&patterns, "anything"));
|
||||
}
|
||||
|
||||
// ── before_tool_call tests ────────────────────────────────────
|
||||
|
||||
fn make_hook(patterns: Vec<&str>, include_args: bool) -> WebhookAuditHook {
|
||||
// Use https URL for tests to pass URL validation; localhost with http
|
||||
// is only allowed in debug builds, but use https to be safe.
|
||||
WebhookAuditHook::new(WebhookAuditConfig {
|
||||
enabled: true,
|
||||
url: "https://audit.example.com/webhook".to_string(),
|
||||
tool_patterns: patterns.into_iter().map(String::from).collect(),
|
||||
include_args,
|
||||
max_args_bytes: 4096,
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn before_tool_call_captures_args_when_enabled() {
|
||||
let hook = make_hook(vec!["Bash", "mcp__*"], true);
|
||||
let args = serde_json::json!({"command": "ls"});
|
||||
let result = hook.before_tool_call("Bash".into(), args.clone()).await;
|
||||
assert!(!result.is_cancel());
|
||||
|
||||
let pending = hook.pending_args.lock().unwrap();
|
||||
assert_eq!(pending.get("Bash"), Some(&vec![args]));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn before_tool_call_concurrent_same_tool_no_data_loss() {
|
||||
let hook = make_hook(vec!["Bash"], true);
|
||||
let args1 = serde_json::json!({"command": "ls"});
|
||||
let args2 = serde_json::json!({"command": "pwd"});
|
||||
hook.before_tool_call("Bash".into(), args1.clone()).await;
|
||||
hook.before_tool_call("Bash".into(), args2.clone()).await;
|
||||
|
||||
let pending = hook.pending_args.lock().unwrap();
|
||||
let bash_args = pending.get("Bash").unwrap();
|
||||
assert_eq!(bash_args.len(), 2);
|
||||
assert_eq!(bash_args[0], args1);
|
||||
assert_eq!(bash_args[1], args2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn before_tool_call_skips_non_matching_tools() {
|
||||
let hook = make_hook(vec!["Bash"], true);
|
||||
let args = serde_json::json!({"path": "/tmp"});
|
||||
let result = hook.before_tool_call("Write".into(), args).await;
|
||||
assert!(!result.is_cancel());
|
||||
|
||||
let pending = hook.pending_args.lock().unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn before_tool_call_skips_when_include_args_false() {
|
||||
let hook = make_hook(vec!["Bash"], false);
|
||||
let args = serde_json::json!({"command": "ls"});
|
||||
let result = hook.before_tool_call("Bash".into(), args).await;
|
||||
assert!(!result.is_cancel());
|
||||
|
||||
let pending = hook.pending_args.lock().unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
// ── Truncation tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn truncate_args_within_limit() {
|
||||
let args = serde_json::json!({"key": "val"});
|
||||
let result = truncate_args(args.clone(), 1000);
|
||||
assert_eq!(result, args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_args_over_limit() {
|
||||
let args = serde_json::json!({"key": "a]long value that exceeds limit"});
|
||||
let result = truncate_args(args, 10);
|
||||
assert!(result.is_string());
|
||||
let s = result.as_str().unwrap();
|
||||
assert!(s.ends_with("...[truncated]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_args_zero_means_no_limit() {
|
||||
let args = serde_json::json!({"key": "value"});
|
||||
let result = truncate_args(args.clone(), 0);
|
||||
assert_eq!(result, args);
|
||||
}
|
||||
|
||||
// ── on_after_tool_call tests ─────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_after_tool_call_skips_non_matching() {
|
||||
let hook = make_hook(vec!["Bash"], true);
|
||||
let result = ToolResult {
|
||||
success: true,
|
||||
output: "ok".into(),
|
||||
error: None,
|
||||
};
|
||||
// Call with a non-matching tool — should not panic or do anything.
|
||||
hook.on_after_tool_call("Write", &result, Duration::from_millis(10))
|
||||
.await;
|
||||
// No assertion needed beyond "doesn't panic"; args map stays empty.
|
||||
let pending = hook.pending_args.lock().unwrap();
|
||||
assert!(pending.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn on_after_tool_call_skips_empty_url() {
|
||||
// Empty URL + enabled triggers a warning, but should not panic.
|
||||
let hook = WebhookAuditHook::new(WebhookAuditConfig {
|
||||
enabled: true,
|
||||
url: String::new(),
|
||||
tool_patterns: vec!["Bash".to_string()],
|
||||
include_args: false,
|
||||
max_args_bytes: 4096,
|
||||
});
|
||||
let result = ToolResult {
|
||||
success: true,
|
||||
output: "ok".into(),
|
||||
error: None,
|
||||
};
|
||||
// Should return immediately without spawning any HTTP request.
|
||||
hook.on_after_tool_call("Bash", &result, Duration::from_millis(5))
|
||||
.await;
|
||||
}
|
||||
|
||||
// ── URL validation tests ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_loopback_ipv4() {
|
||||
assert!(validate_webhook_url("https://127.0.0.1/hook").is_err());
|
||||
assert!(validate_webhook_url("https://127.0.0.100/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_loopback_ipv6() {
|
||||
assert!(validate_webhook_url("https://[::1]/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_private_rfc1918() {
|
||||
assert!(validate_webhook_url("https://10.0.0.1/hook").is_err());
|
||||
assert!(validate_webhook_url("https://172.16.5.1/hook").is_err());
|
||||
assert!(validate_webhook_url("https://192.168.1.1/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_link_local() {
|
||||
assert!(validate_webhook_url("https://169.254.1.1/hook").is_err());
|
||||
assert!(validate_webhook_url("https://[fe80::1]/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_http_non_localhost() {
|
||||
assert!(validate_webhook_url("http://example.com/hook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_accepts_https_public() {
|
||||
assert!(validate_webhook_url("https://audit.example.com/webhook").is_ok());
|
||||
assert!(validate_webhook_url("https://8.8.8.8/hook").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_url_rejects_non_http_scheme() {
|
||||
assert!(validate_webhook_url("ftp://example.com/hook").is_err());
|
||||
}
|
||||
}
|
||||
@ -364,6 +364,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
||||
}
|
||||
},
|
||||
},
|
||||
IntegrationEntry {
|
||||
name: "OpenCode Go",
|
||||
description: "Subsidized Code-focused AI models",
|
||||
category: IntegrationCategory::AiModel,
|
||||
status_fn: |c| {
|
||||
if c.default_provider.as_deref() == Some("opencode-go") {
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
}
|
||||
},
|
||||
},
|
||||
IntegrationEntry {
|
||||
name: "Z.AI",
|
||||
description: "Z.AI inference",
|
||||
|
||||
25
src/lib.rs
25
src/lib.rs
@ -202,6 +202,31 @@ Examples:
|
||||
/// Telegram identity to allow (username without '@' or numeric user ID)
|
||||
identity: String,
|
||||
},
|
||||
/// Send a message to a configured channel
|
||||
#[command(long_about = "\
|
||||
Send a one-off message to a configured channel.
|
||||
|
||||
Sends a text message through the specified channel without starting \
|
||||
the full agent loop. Useful for scripted notifications, hardware \
|
||||
sensor alerts, and automation pipelines.
|
||||
|
||||
The --channel-id selects the channel by its config section name \
|
||||
(e.g. 'telegram', 'discord', 'slack'). The --recipient is the \
|
||||
platform-specific destination (e.g. a Telegram chat ID).
|
||||
|
||||
Examples:
|
||||
zeroclaw channel send 'Someone is near your device.' --channel-id telegram --recipient 123456789
|
||||
zeroclaw channel send 'Build succeeded!' --channel-id discord --recipient 987654321")]
|
||||
Send {
|
||||
/// Message text to send
|
||||
message: String,
|
||||
/// Channel config name (e.g. telegram, discord, slack)
|
||||
#[arg(long)]
|
||||
channel_id: String,
|
||||
/// Recipient identifier (platform-specific, e.g. Telegram chat ID)
|
||||
#[arg(long)]
|
||||
recipient: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Skills management subcommands
|
||||
|
||||
65
src/main.rs
65
src/main.rs
@ -140,6 +140,10 @@ enum Commands {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
|
||||
/// Reinitialize from scratch (backup and reset all configuration)
|
||||
#[arg(long)]
|
||||
reinit: bool,
|
||||
|
||||
/// Reconfigure channels only (fast repair flow)
|
||||
#[arg(long)]
|
||||
channels_only: bool,
|
||||
@ -184,7 +188,7 @@ Examples:
|
||||
#[arg(long)]
|
||||
model: Option<String>,
|
||||
|
||||
/// Temperature override (0.0 - 2.0). Defaults to config.default_temperature when omitted.
|
||||
/// Temperature (0.0 - 2.0, defaults to config default_temperature)
|
||||
#[arg(short, long, value_parser = parse_temperature)]
|
||||
temperature: Option<f64>,
|
||||
|
||||
@ -320,7 +324,7 @@ Examples:
|
||||
#[command(long_about = "\
|
||||
Manage communication channels.
|
||||
|
||||
Add, remove, list, and health-check channels that connect ZeroClaw \
|
||||
Add, remove, list, send, and health-check channels that connect ZeroClaw \
|
||||
to messaging platforms. Supported channel types: telegram, discord, \
|
||||
slack, whatsapp, matrix, imessage, email.
|
||||
|
||||
@ -329,7 +333,8 @@ Examples:
|
||||
zeroclaw channel doctor
|
||||
zeroclaw channel add telegram '{\"bot_token\":\"...\",\"name\":\"my-bot\"}'
|
||||
zeroclaw channel remove my-bot
|
||||
zeroclaw channel bind-telegram zeroclaw_user")]
|
||||
zeroclaw channel bind-telegram zeroclaw_user
|
||||
zeroclaw channel send 'Alert!' --channel-id telegram --recipient 123456789")]
|
||||
Channel {
|
||||
#[command(subcommand)]
|
||||
channel_command: ChannelCommands,
|
||||
@ -690,6 +695,7 @@ async fn main() -> Result<()> {
|
||||
if let Commands::Onboard {
|
||||
interactive,
|
||||
force,
|
||||
reinit,
|
||||
channels_only,
|
||||
api_key,
|
||||
provider,
|
||||
@ -699,6 +705,7 @@ async fn main() -> Result<()> {
|
||||
{
|
||||
let interactive = *interactive;
|
||||
let force = *force;
|
||||
let reinit = *reinit;
|
||||
let channels_only = *channels_only;
|
||||
let api_key = api_key.clone();
|
||||
let provider = provider.clone();
|
||||
@ -708,6 +715,12 @@ async fn main() -> Result<()> {
|
||||
if interactive && channels_only {
|
||||
bail!("Use either --interactive or --channels-only, not both");
|
||||
}
|
||||
if reinit && channels_only {
|
||||
bail!("Use either --reinit or --channels-only, not both");
|
||||
}
|
||||
if reinit && !interactive {
|
||||
bail!("--reinit requires --interactive mode");
|
||||
}
|
||||
if channels_only
|
||||
&& (api_key.is_some() || provider.is_some() || model.is_some() || memory.is_some())
|
||||
{
|
||||
@ -716,6 +729,48 @@ async fn main() -> Result<()> {
|
||||
if channels_only && force {
|
||||
bail!("--channels-only does not accept --force");
|
||||
}
|
||||
|
||||
// Handle --reinit: backup and reset configuration
|
||||
if reinit {
|
||||
let (zeroclaw_dir, _) =
|
||||
crate::config::schema::resolve_runtime_dirs_for_onboarding().await?;
|
||||
|
||||
if zeroclaw_dir.exists() {
|
||||
let timestamp = chrono::Local::now().format("%Y%m%d%H%M%S");
|
||||
let backup_dir = format!("{}.backup.{}", zeroclaw_dir.display(), timestamp);
|
||||
|
||||
println!("⚠️ Reinitializing ZeroClaw configuration...");
|
||||
println!(" Current config directory: {}", zeroclaw_dir.display());
|
||||
println!(
|
||||
" This will back up your existing config to: {}",
|
||||
backup_dir
|
||||
);
|
||||
println!();
|
||||
print!("Continue? [y/N] ");
|
||||
std::io::stdout()
|
||||
.flush()
|
||||
.context("Failed to flush stdout")?;
|
||||
|
||||
let mut answer = String::new();
|
||||
std::io::stdin().read_line(&mut answer)?;
|
||||
if !answer.trim().eq_ignore_ascii_case("y") {
|
||||
println!("Aborted.");
|
||||
return Ok(());
|
||||
}
|
||||
println!();
|
||||
|
||||
// Rename existing directory as backup
|
||||
tokio::fs::rename(&zeroclaw_dir, &backup_dir)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("Failed to backup existing config to {}", backup_dir)
|
||||
})?;
|
||||
|
||||
println!(" Backup created successfully.");
|
||||
println!(" Starting fresh initialization...\n");
|
||||
}
|
||||
}
|
||||
|
||||
let config = if channels_only {
|
||||
Box::pin(onboard::run_channels_repair_wizard()).await
|
||||
} else if interactive {
|
||||
@ -765,10 +820,6 @@ async fn main() -> Result<()> {
|
||||
temperature,
|
||||
peripheral,
|
||||
} => {
|
||||
// Implement temperature fallback logic:
|
||||
// 1. Use --temperature if provided
|
||||
// 2. Use config.default_temperature if --temperature not provided
|
||||
// 3. Use hardcoded 0.7 if config.default_temperature not set (from Config::default())
|
||||
let final_temperature = temperature.unwrap_or(config.default_temperature);
|
||||
|
||||
agent::run(
|
||||
|
||||
@ -27,7 +27,7 @@ impl Observer for LogObserver {
|
||||
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
|
||||
info!(provider = %provider, model = %model, duration_ms = ms, tokens = ?tokens_used, cost_usd = ?cost_usd, "agent.end");
|
||||
}
|
||||
ObserverEvent::ToolCallStart { tool } => {
|
||||
ObserverEvent::ToolCallStart { tool, .. } => {
|
||||
info!(tool = %tool, "tool.start");
|
||||
}
|
||||
ObserverEvent::ToolCall {
|
||||
|
||||
@ -434,6 +434,7 @@ mod tests {
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ToolCallStart {
|
||||
tool: "shell".into(),
|
||||
arguments: None,
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ToolCall {
|
||||
tool: "shell".into(),
|
||||
|
||||
@ -221,7 +221,7 @@ impl Observer for PrometheusObserver {
|
||||
.inc_by(*output);
|
||||
}
|
||||
}
|
||||
ObserverEvent::ToolCallStart { tool: _ }
|
||||
ObserverEvent::ToolCallStart { .. }
|
||||
| ObserverEvent::TurnComplete
|
||||
| ObserverEvent::LlmRequest { .. } => {}
|
||||
ObserverEvent::ToolCall {
|
||||
|
||||
@ -40,7 +40,10 @@ pub enum ObserverEvent {
|
||||
cost_usd: Option<f64>,
|
||||
},
|
||||
/// A tool call is about to be executed.
|
||||
ToolCallStart { tool: String },
|
||||
ToolCallStart {
|
||||
tool: String,
|
||||
arguments: Option<String>,
|
||||
},
|
||||
/// A tool call has completed with a success/failure outcome.
|
||||
ToolCall {
|
||||
tool: String,
|
||||
|
||||
@ -33,7 +33,7 @@ impl Observer for VerboseObserver {
|
||||
let ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
|
||||
eprintln!("< Receive (success={success}, duration_ms={ms})");
|
||||
}
|
||||
ObserverEvent::ToolCallStart { tool } => {
|
||||
ObserverEvent::ToolCallStart { tool, .. } => {
|
||||
eprintln!("> Tool {tool}");
|
||||
}
|
||||
ObserverEvent::ToolCall {
|
||||
@ -92,6 +92,7 @@ mod tests {
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ToolCallStart {
|
||||
tool: "shell".into(),
|
||||
arguments: None,
|
||||
});
|
||||
obs.record_event(&ObserverEvent::ToolCall {
|
||||
tool: "shell".into(),
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
use crate::config::schema::{default_nostr_relays, NostrConfig};
|
||||
use crate::config::schema::{
|
||||
default_nostr_relays, DingTalkConfig, IrcConfig, LarkReceiveMode, LinqConfig,
|
||||
NextcloudTalkConfig, NostrConfig, QQConfig, SignalConfig, StreamMode, WhatsAppConfig,
|
||||
DingTalkConfig, IrcConfig, LarkReceiveMode, LinqConfig, NextcloudTalkConfig, QQConfig,
|
||||
SignalConfig, StreamMode, WhatsAppConfig,
|
||||
};
|
||||
use crate::config::{
|
||||
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||
@ -136,6 +138,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
default_model: Some(model),
|
||||
model_providers: std::collections::HashMap::new(),
|
||||
default_temperature: 0.7,
|
||||
provider_timeout_secs: 120,
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
security: crate::config::SecurityConfig::default(),
|
||||
@ -488,6 +491,7 @@ async fn run_quick_setup_with_home(
|
||||
default_model: Some(model.clone()),
|
||||
model_providers: std::collections::HashMap::new(),
|
||||
default_temperature: 0.7,
|
||||
provider_timeout_secs: 120,
|
||||
observability: ObservabilityConfig::default(),
|
||||
autonomy: AutonomyConfig::default(),
|
||||
security: crate::config::SecurityConfig::default(),
|
||||
@ -711,7 +715,7 @@ fn default_model_for_provider(provider: &str) -> String {
|
||||
"qwen-code" => "qwen3-coder-plus".into(),
|
||||
"ollama" => "llama3.2".into(),
|
||||
"llamacpp" => "ggml-org/gpt-oss-20b-GGUF".into(),
|
||||
"sglang" | "vllm" | "osaurus" => "default".into(),
|
||||
"sglang" | "vllm" | "osaurus" | "opencode-go" => "default".into(),
|
||||
"gemini" => "gemini-2.5-pro".into(),
|
||||
"kimi-code" => "kimi-for-coding".into(),
|
||||
"bedrock" => "anthropic.claude-sonnet-4-5-20250929-v1:0".into(),
|
||||
@ -1163,6 +1167,7 @@ fn supports_live_model_fetch(provider_name: &str) -> bool {
|
||||
| "zai"
|
||||
| "qwen"
|
||||
| "nvidia"
|
||||
| "opencode-go"
|
||||
)
|
||||
}
|
||||
|
||||
@ -1194,6 +1199,7 @@ fn models_endpoint_for_provider(provider_name: &str) -> Option<&'static str> {
|
||||
"sglang" => Some("http://localhost:30000/v1/models"),
|
||||
"vllm" => Some("http://localhost:8000/v1/models"),
|
||||
"osaurus" => Some("http://localhost:1337/v1/models"),
|
||||
"opencode-go" => Some("https://opencode.ai/zen/go/v1/models"),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
@ -2195,6 +2201,7 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String,
|
||||
("zai-cn", "Z.AI — China coding endpoint (open.bigmodel.cn)"),
|
||||
("synthetic", "Synthetic — Synthetic AI models"),
|
||||
("opencode", "OpenCode Zen — code-focused AI"),
|
||||
("opencode-go", "OpenCode Go — Subsidized code-focused AI"),
|
||||
("cohere", "Cohere — Command R+ & embeddings"),
|
||||
],
|
||||
4 => local_provider_choices(),
|
||||
@ -2879,6 +2886,7 @@ fn provider_env_var(name: &str) -> &'static str {
|
||||
"zai" => "ZAI_API_KEY",
|
||||
"synthetic" => "SYNTHETIC_API_KEY",
|
||||
"opencode" | "opencode-zen" => "OPENCODE_API_KEY",
|
||||
"opencode-go" => "OPENCODE_GO_API_KEY",
|
||||
"vercel" | "vercel-ai" => "VERCEL_API_KEY",
|
||||
"cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY",
|
||||
"bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID",
|
||||
@ -3337,6 +3345,7 @@ enum ChannelMenuChoice {
|
||||
QqOfficial,
|
||||
Lark,
|
||||
Feishu,
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
Nostr,
|
||||
Done,
|
||||
}
|
||||
@ -3357,6 +3366,7 @@ const CHANNEL_MENU_CHOICES: &[ChannelMenuChoice] = &[
|
||||
ChannelMenuChoice::QqOfficial,
|
||||
ChannelMenuChoice::Lark,
|
||||
ChannelMenuChoice::Feishu,
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
ChannelMenuChoice::Nostr,
|
||||
ChannelMenuChoice::Done,
|
||||
];
|
||||
@ -3500,6 +3510,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
"— Feishu Bot"
|
||||
}
|
||||
),
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
ChannelMenuChoice::Nostr => format!(
|
||||
"Nostr {}",
|
||||
if config.nostr.is_some() {
|
||||
@ -4945,6 +4956,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
port,
|
||||
});
|
||||
}
|
||||
#[cfg(feature = "channel-nostr")]
|
||||
ChannelMenuChoice::Nostr => {
|
||||
// ── Nostr ──
|
||||
println!();
|
||||
@ -7051,6 +7063,7 @@ mod tests {
|
||||
assert_eq!(provider_env_var("nvidia-nim"), "NVIDIA_API_KEY"); // alias
|
||||
assert_eq!(provider_env_var("build.nvidia.com"), "NVIDIA_API_KEY"); // alias
|
||||
assert_eq!(provider_env_var("astrai"), "ASTRAI_API_KEY");
|
||||
assert_eq!(provider_env_var("opencode-go"), "OPENCODE_GO_API_KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, TokenUsage, ToolCall as ProviderToolCall,
|
||||
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine as _;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@ -59,6 +60,14 @@ struct NativeMessage {
|
||||
content: Vec<NativeContentOut>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ImageSource {
|
||||
#[serde(rename = "type")]
|
||||
source_type: String,
|
||||
media_type: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum NativeContentOut {
|
||||
@ -68,6 +77,8 @@ enum NativeContentOut {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cache_control: Option<CacheControl>,
|
||||
},
|
||||
#[serde(rename = "image")]
|
||||
Image { source: ImageSource },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
@ -210,7 +221,7 @@ impl AnthropicProvider {
|
||||
| NativeContentOut::ToolResult { cache_control, .. } => {
|
||||
*cache_control = Some(CacheControl::ephemeral());
|
||||
}
|
||||
NativeContentOut::ToolUse { .. } => {}
|
||||
NativeContentOut::ToolUse { .. } | NativeContentOut::Image { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -332,12 +343,71 @@ impl AnthropicProvider {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Parse image markers from user message content
|
||||
let (text, image_refs) = crate::multimodal::parse_image_markers(&msg.content);
|
||||
let mut content_blocks: Vec<NativeContentOut> = Vec::new();
|
||||
|
||||
// Add image content blocks for each image reference
|
||||
for img_ref in &image_refs {
|
||||
let (media_type, data) = if img_ref.starts_with("data:") {
|
||||
// Data URI format: data:image/jpeg;base64,/9j/4AAQ...
|
||||
if let Some(comma) = img_ref.find(',') {
|
||||
let header = &img_ref[5..comma];
|
||||
let mime =
|
||||
header.split(';').next().unwrap_or("image/jpeg").to_string();
|
||||
let b64 = img_ref[comma + 1..].trim().to_string();
|
||||
(mime, b64)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else if std::path::Path::new(img_ref.trim()).exists() {
|
||||
// Local file path
|
||||
match std::fs::read(img_ref.trim()) {
|
||||
Ok(bytes) => {
|
||||
let b64 =
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
let ext = std::path::Path::new(img_ref.trim())
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("jpg");
|
||||
let mime = match ext {
|
||||
"png" => "image/png",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
}
|
||||
.to_string();
|
||||
(mime, b64)
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
content_blocks.push(NativeContentOut::Image {
|
||||
source: ImageSource {
|
||||
source_type: "base64".to_string(),
|
||||
media_type,
|
||||
data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add text content block
|
||||
let display_text = if text.is_empty() && !image_refs.is_empty() {
|
||||
"[image]".to_string()
|
||||
} else {
|
||||
text
|
||||
};
|
||||
content_blocks.push(NativeContentOut::Text {
|
||||
text: display_text,
|
||||
cache_control: None,
|
||||
});
|
||||
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
cache_control: None,
|
||||
}],
|
||||
content: content_blocks,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -510,6 +580,13 @@ impl Provider for AnthropicProvider {
|
||||
Ok(Self::parse_native_response(native_response))
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@ -1353,4 +1430,124 @@ mod tests {
|
||||
let result = AnthropicProvider::parse_native_response(resp);
|
||||
assert!(result.usage.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_returns_vision_and_native_tools() {
|
||||
let provider = AnthropicProvider::new(Some("test-key"));
|
||||
let caps = provider.capabilities();
|
||||
assert!(
|
||||
caps.native_tool_calling,
|
||||
"Anthropic should support native tool calling"
|
||||
);
|
||||
assert!(caps.vision, "Anthropic should support vision");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_messages_with_image_marker_data_uri() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Check this image: [IMAGE:data:image/jpeg;base64,/9j/4AAQ] What do you see?"
|
||||
.to_string(),
|
||||
}];
|
||||
|
||||
let (_, native_msgs) = AnthropicProvider::convert_messages(&messages);
|
||||
|
||||
assert_eq!(native_msgs.len(), 1);
|
||||
assert_eq!(native_msgs[0].role, "user");
|
||||
// Should have 2 content blocks: image + text
|
||||
assert_eq!(native_msgs[0].content.len(), 2);
|
||||
|
||||
// First block should be image
|
||||
match &native_msgs[0].content[0] {
|
||||
NativeContentOut::Image { source } => {
|
||||
assert_eq!(source.source_type, "base64");
|
||||
assert_eq!(source.media_type, "image/jpeg");
|
||||
assert_eq!(source.data, "/9j/4AAQ");
|
||||
}
|
||||
_ => panic!("Expected Image content block"),
|
||||
}
|
||||
|
||||
// Second block should be text (parse_image_markers may leave extra spaces)
|
||||
match &native_msgs[0].content[1] {
|
||||
NativeContentOut::Text { text, .. } => {
|
||||
// The text may have extra spaces where the marker was removed
|
||||
assert!(
|
||||
text.contains("Check this image:") && text.contains("What do you see?"),
|
||||
"Expected text to contain 'Check this image:' and 'What do you see?', got: {}",
|
||||
text
|
||||
);
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_messages_with_only_image_marker() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "[IMAGE:data:image/png;base64,iVBORw0KGgo]".to_string(),
|
||||
}];
|
||||
|
||||
let (_, native_msgs) = AnthropicProvider::convert_messages(&messages);
|
||||
|
||||
assert_eq!(native_msgs.len(), 1);
|
||||
assert_eq!(native_msgs[0].content.len(), 2);
|
||||
|
||||
// First block should be image
|
||||
match &native_msgs[0].content[0] {
|
||||
NativeContentOut::Image { source } => {
|
||||
assert_eq!(source.media_type, "image/png");
|
||||
}
|
||||
_ => panic!("Expected Image content block"),
|
||||
}
|
||||
|
||||
// Second block should be placeholder text
|
||||
match &native_msgs[0].content[1] {
|
||||
NativeContentOut::Text { text, .. } => {
|
||||
assert_eq!(text, "[image]");
|
||||
}
|
||||
_ => panic!("Expected Text content block with [image] placeholder"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_messages_without_image_marker() {
|
||||
let messages = vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Hello, how are you?".to_string(),
|
||||
}];
|
||||
|
||||
let (_, native_msgs) = AnthropicProvider::convert_messages(&messages);
|
||||
|
||||
assert_eq!(native_msgs.len(), 1);
|
||||
assert_eq!(native_msgs[0].content.len(), 1);
|
||||
|
||||
match &native_msgs[0].content[0] {
|
||||
NativeContentOut::Text { text, .. } => {
|
||||
assert_eq!(text, "Hello, how are you?");
|
||||
}
|
||||
_ => panic!("Expected Text content block"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_content_serializes_correctly() {
|
||||
let content = NativeContentOut::Image {
|
||||
source: ImageSource {
|
||||
source_type: "base64".to_string(),
|
||||
media_type: "image/jpeg".to_string(),
|
||||
data: "testdata".to_string(),
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&content).unwrap();
|
||||
// The outer "type" is the enum tag, inner "type" (source_type) is renamed
|
||||
assert!(json.contains(r#""type":"image""#), "JSON: {}", json);
|
||||
assert!(json.contains(r#""type":"base64""#), "JSON: {}", json); // source_type is serialized as "type"
|
||||
assert!(
|
||||
json.contains(r#""media_type":"image/jpeg""#),
|
||||
"JSON: {}",
|
||||
json
|
||||
);
|
||||
assert!(json.contains(r#""data":"testdata""#), "JSON: {}", json);
|
||||
}
|
||||
}
|
||||
|
||||
756
src/providers/azure_openai.rs
Normal file
756
src/providers/azure_openai.rs
Normal file
@ -0,0 +1,756 @@
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ProviderCapabilities, TokenUsage, ToolCall as ProviderToolCall, ToolsPayload,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_API_VERSION: &str = "2024-08-01-preview";
|
||||
|
||||
pub struct AzureOpenAiProvider {
|
||||
credential: Option<String>,
|
||||
resource_name: String,
|
||||
deployment_name: String,
|
||||
api_version: String,
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatRequest {
|
||||
messages: Vec<Message>,
|
||||
temperature: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Message {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Choice {
|
||||
message: ResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
impl ResponseMessage {
|
||||
fn effective_content(&self) -> String {
|
||||
match &self.content {
|
||||
Some(c) if !c.is_empty() => c.clone(),
|
||||
_ => self.reasoning_content.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolSpec {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
function: NativeToolFunctionSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolFunctionSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
fn parse_native_tool_spec(value: serde_json::Value) -> anyhow::Result<NativeToolSpec> {
|
||||
let spec: NativeToolSpec = serde_json::from_value(value)
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Azure OpenAI tool specification: {e}"))?;
|
||||
|
||||
if spec.kind != "function" {
|
||||
anyhow::bail!(
|
||||
"Invalid Azure OpenAI tool specification: unsupported tool type '{}', expected 'function'",
|
||||
spec.kind
|
||||
);
|
||||
}
|
||||
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
function: NativeFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
choices: Vec<NativeChoice>,
|
||||
#[serde(default)]
|
||||
usage: Option<UsageInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UsageInfo {
|
||||
#[serde(default)]
|
||||
prompt_tokens: Option<u64>,
|
||||
#[serde(default)]
|
||||
completion_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChoice {
|
||||
message: NativeResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
impl NativeResponseMessage {
|
||||
fn effective_content(&self) -> Option<String> {
|
||||
match &self.content {
|
||||
Some(c) if !c.is_empty() => Some(c.clone()),
|
||||
_ => self.reasoning_content.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AzureOpenAiProvider {
|
||||
pub fn new(
|
||||
credential: Option<&str>,
|
||||
resource_name: &str,
|
||||
deployment_name: &str,
|
||||
api_version: Option<&str>,
|
||||
) -> Self {
|
||||
let version = api_version.unwrap_or(DEFAULT_API_VERSION);
|
||||
let base_url = format!(
|
||||
"https://{}.openai.azure.com/openai/deployments/{}",
|
||||
resource_name, deployment_name
|
||||
);
|
||||
Self {
|
||||
credential: credential.map(ToString::to_string),
|
||||
resource_name: resource_name.to_string(),
|
||||
deployment_name: deployment_name.to_string(),
|
||||
api_version: version.to_string(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_completions_url(&self) -> String {
|
||||
format!(
|
||||
"{}/chat/completions?api-version={}",
|
||||
self.base_url, self.api_version
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
tools.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
kind: "function".to_string(),
|
||||
function: NativeToolFunctionSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.parameters.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if m.role == "assistant" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
if let Some(tool_calls_value) = value.get("tool_calls") {
|
||||
if let Ok(parsed_calls) =
|
||||
serde_json::from_value::<Vec<ProviderToolCall>>(
|
||||
tool_calls_value.clone(),
|
||||
)
|
||||
{
|
||||
let tool_calls = parsed_calls
|
||||
.into_iter()
|
||||
.map(|tc| NativeToolCall {
|
||||
id: Some(tc.id),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let reasoning_content = value
|
||||
.get("reasoning_content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_call_id: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
reasoning_content,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.role == "tool" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
let tool_call_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "tool".to_string(),
|
||||
content,
|
||||
tool_call_id,
|
||||
tool_calls: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
NativeMessage {
|
||||
role: m.role.clone(),
|
||||
content: Some(m.content.clone()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
reasoning_content: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
|
||||
let text = message.effective_content();
|
||||
let reasoning_content = message.reasoning_content.clone();
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| ProviderToolCall {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ProviderChatResponse {
|
||||
text,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
reasoning_content,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.azure_openai", 120, 10)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for AzureOpenAiProvider {
|
||||
fn capabilities(&self) -> ProviderCapabilities {
|
||||
ProviderCapabilities {
|
||||
native_tool_calling: true,
|
||||
vision: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(&self, tools: &[ToolSpec]) -> ToolsPayload {
|
||||
ToolsPayload::OpenAI {
|
||||
tools: tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn supports_vision(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
_model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Azure OpenAI API key not set. Set AZURE_OPENAI_API_KEY or edit config.toml."
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Some(sys) = system_prompt {
|
||||
messages.push(Message {
|
||||
role: "system".to_string(),
|
||||
content: sys.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: message.to_string(),
|
||||
});
|
||||
|
||||
let request = ChatRequest {
|
||||
messages,
|
||||
temperature,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http_client()
|
||||
.post(self.chat_completions_url())
|
||||
.header("api-key", credential.as_str())
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("Azure OpenAI", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message.effective_content())
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Azure OpenAI"))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
_model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Azure OpenAI API key not set. Set AZURE_OPENAI_API_KEY or edit config.toml."
|
||||
)
|
||||
})?;
|
||||
|
||||
let tools = Self::convert_tools(request.tools);
|
||||
let native_request = NativeChatRequest {
|
||||
messages: Self::convert_messages(request.messages),
|
||||
temperature,
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http_client()
|
||||
.post(self.chat_completions_url())
|
||||
.header("api-key", credential.as_str())
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("Azure OpenAI", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
});
|
||||
let message = native_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Azure OpenAI"))?;
|
||||
let mut result = Self::parse_native_response(message);
|
||||
result.usage = usage;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn chat_with_tools(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: &[serde_json::Value],
|
||||
_model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Azure OpenAI API key not set. Set AZURE_OPENAI_API_KEY or edit config.toml."
|
||||
)
|
||||
})?;
|
||||
|
||||
let native_tools: Option<Vec<NativeToolSpec>> = if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
tools
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(parse_native_tool_spec)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
)
|
||||
};
|
||||
|
||||
let native_request = NativeChatRequest {
|
||||
messages: Self::convert_messages(messages),
|
||||
temperature,
|
||||
tool_choice: native_tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools: native_tools,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.http_client()
|
||||
.post(self.chat_completions_url())
|
||||
.header("api-key", credential.as_str())
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("Azure OpenAI", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
});
|
||||
let message = native_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Azure OpenAI"))?;
|
||||
let mut result = Self::parse_native_response(message);
|
||||
result.usage = usage;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
// Azure OpenAI does not have a lightweight models endpoint,
|
||||
// so warmup is a no-op to avoid unnecessary API calls.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_construction_default_version() {
|
||||
let p = AzureOpenAiProvider::new(Some("test-key"), "my-resource", "gpt-4o", None);
|
||||
assert_eq!(
|
||||
p.chat_completions_url(),
|
||||
"https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-08-01-preview"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_construction_custom_version() {
|
||||
let p = AzureOpenAiProvider::new(
|
||||
Some("test-key"),
|
||||
"my-resource",
|
||||
"gpt-4o",
|
||||
Some("2024-06-01"),
|
||||
);
|
||||
assert_eq!(
|
||||
p.chat_completions_url(),
|
||||
"https://my-resource.openai.azure.com/openai/deployments/gpt-4o/chat/completions?api-version=2024-06-01"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_construction_preserves_resource_and_deployment() {
|
||||
let p = AzureOpenAiProvider::new(Some("key"), "contoso-ai", "my-gpt35-deployment", None);
|
||||
let url = p.chat_completions_url();
|
||||
assert!(url.contains("contoso-ai.openai.azure.com"));
|
||||
assert!(url.contains("/deployments/my-gpt35-deployment/"));
|
||||
assert!(url.contains("api-version=2024-08-01-preview"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_header_uses_api_key_not_bearer() {
|
||||
// This test verifies the provider stores the credential correctly
|
||||
// and that the auth header name is "api-key" (verified via the
|
||||
// implementation in chat_with_system which uses .header("api-key", ...)).
|
||||
let p = AzureOpenAiProvider::new(Some("my-azure-key"), "resource", "deployment", None);
|
||||
assert_eq!(p.credential.as_deref(), Some("my-azure-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_with_credential() {
|
||||
let p = AzureOpenAiProvider::new(
|
||||
Some("azure-test-credential"),
|
||||
"resource",
|
||||
"deployment",
|
||||
None,
|
||||
);
|
||||
assert_eq!(p.credential.as_deref(), Some("azure-test-credential"));
|
||||
assert_eq!(p.resource_name, "resource");
|
||||
assert_eq!(p.deployment_name, "deployment");
|
||||
assert_eq!(p.api_version, DEFAULT_API_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creates_without_credential() {
|
||||
let p = AzureOpenAiProvider::new(None, "resource", "deployment", None);
|
||||
assert!(p.credential.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_fails_without_key() {
|
||||
let p = AzureOpenAiProvider::new(None, "resource", "deployment", None);
|
||||
let result = p.chat_with_system(None, "hello", "gpt-4o", 0.7).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("API key not set"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_system_fails_without_key() {
|
||||
let p = AzureOpenAiProvider::new(None, "resource", "deployment", None);
|
||||
let result = p
|
||||
.chat_with_system(Some("You are ZeroClaw"), "test", "gpt-4o", 0.5)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_with_system_message() {
|
||||
let req = ChatRequest {
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "system".to_string(),
|
||||
content: "You are ZeroClaw".to_string(),
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
},
|
||||
],
|
||||
temperature: 0.7,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(json.contains("\"role\":\"system\""));
|
||||
assert!(json.contains("\"role\":\"user\""));
|
||||
// Azure requests should NOT contain a model field (deployment is in the URL)
|
||||
assert!(!json.contains("\"model\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_serializes_without_system() {
|
||||
let req = ChatRequest {
|
||||
messages: vec![Message {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string(),
|
||||
}],
|
||||
temperature: 0.0,
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("system"));
|
||||
assert!(json.contains("\"temperature\":0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserializes_single_choice() {
|
||||
let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#;
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices.len(), 1);
|
||||
assert_eq!(resp.choices[0].message.effective_content(), "Hi!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserializes_empty_choices() {
|
||||
let json = r#"{"choices":[]}"#;
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.choices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserializes_multiple_choices() {
|
||||
let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#;
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices.len(), 2);
|
||||
assert_eq!(resp.choices[0].message.effective_content(), "A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_response_parsing() {
|
||||
let json = r#"{"choices":[{"message":{
|
||||
"content":"Let me check",
|
||||
"tool_calls":[{
|
||||
"id":"call_abc123",
|
||||
"type":"function",
|
||||
"function":{"name":"shell","arguments":"{\"command\":\"ls\"}"}
|
||||
}]
|
||||
}}],"usage":{"prompt_tokens":50,"completion_tokens":25}}"#;
|
||||
let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
|
||||
let message = resp.choices.into_iter().next().unwrap().message;
|
||||
let parsed = AzureOpenAiProvider::parse_native_response(message);
|
||||
assert_eq!(parsed.text.as_deref(), Some("Let me check"));
|
||||
assert_eq!(parsed.tool_calls.len(), 1);
|
||||
assert_eq!(parsed.tool_calls[0].id, "call_abc123");
|
||||
assert_eq!(parsed.tool_calls[0].name, "shell");
|
||||
assert!(parsed.tool_calls[0].arguments.contains("ls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_response_without_id_generates_uuid() {
|
||||
let json = r#"{"choices":[{"message":{
|
||||
"content":null,
|
||||
"tool_calls":[{
|
||||
"function":{"name":"test","arguments":"{}"}
|
||||
}]
|
||||
}}]}"#;
|
||||
let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
|
||||
let message = resp.choices.into_iter().next().unwrap().message;
|
||||
let parsed = AzureOpenAiProvider::parse_native_response(message);
|
||||
assert_eq!(parsed.tool_calls.len(), 1);
|
||||
assert!(!parsed.tool_calls[0].id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_tools_fails_without_key() {
|
||||
let p = AzureOpenAiProvider::new(None, "resource", "deployment", None);
|
||||
let messages = vec![ChatMessage::user("hello".to_string())];
|
||||
let tools = vec![serde_json::json!({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "shell",
|
||||
"description": "Run a shell command",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": { "type": "string" }
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
}
|
||||
})];
|
||||
let result = p.chat_with_tools(&messages, &tools, "gpt-4o", 0.7).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("API key not set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_response_parses_usage() {
|
||||
let json = r#"{
|
||||
"choices": [{"message": {"content": "Hello"}}],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
}"#;
|
||||
let resp: NativeChatResponse = serde_json::from_str(json).unwrap();
|
||||
let usage = resp.usage.unwrap();
|
||||
assert_eq!(usage.prompt_tokens, Some(100));
|
||||
assert_eq!(usage.completion_tokens, Some(50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capabilities_reports_native_tools_and_vision() {
|
||||
let p = AzureOpenAiProvider::new(Some("key"), "resource", "deployment", None);
|
||||
let caps = <AzureOpenAiProvider as Provider>::capabilities(&p);
|
||||
assert!(caps.native_tool_calling);
|
||||
assert!(caps.vision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_native_tools_returns_true() {
|
||||
let p = AzureOpenAiProvider::new(Some("key"), "resource", "deployment", None);
|
||||
assert!(p.supports_native_tools());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supports_vision_returns_true() {
|
||||
let p = AzureOpenAiProvider::new(Some("key"), "resource", "deployment", None);
|
||||
assert!(p.supports_vision());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warmup_is_noop() {
|
||||
let p = AzureOpenAiProvider::new(None, "resource", "deployment", None);
|
||||
let result = p.warmup().await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_api_version_stored() {
|
||||
let p = AzureOpenAiProvider::new(Some("key"), "resource", "deployment", Some("2025-01-01"));
|
||||
assert_eq!(p.api_version, "2025-01-01");
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A provider that speaks the OpenAI-compatible chat completions API.
|
||||
/// Used by: Venice, Vercel AI Gateway, Cloudflare AI Gateway, Moonshot,
|
||||
/// Synthetic, `OpenCode` Zen, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc.
|
||||
/// Synthetic, `OpenCode` Zen, `OpenCode` Go, `Z.AI`, `GLM`, `MiniMax`, Bedrock, Qianfan, Groq, Mistral, `xAI`, etc.
|
||||
#[allow(clippy::struct_excessive_bools)]
|
||||
pub struct OpenAiCompatibleProvider {
|
||||
pub(crate) name: String,
|
||||
@ -37,6 +37,8 @@ pub struct OpenAiCompatibleProvider {
|
||||
/// Whether this provider supports OpenAI-style native tool calling.
|
||||
/// When false, tools are injected into the system prompt as text.
|
||||
native_tool_calling: bool,
|
||||
/// HTTP request timeout in seconds for LLM API calls. Default: 120.
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// How the provider expects the API key to be sent.
|
||||
@ -170,9 +172,16 @@ impl OpenAiCompatibleProvider {
|
||||
user_agent: user_agent.map(ToString::to_string),
|
||||
merge_system_into_user,
|
||||
native_tool_calling: !merge_system_into_user,
|
||||
timeout_secs: 120,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the HTTP request timeout for LLM API calls.
|
||||
pub fn with_timeout_secs(mut self, timeout_secs: u64) -> Self {
|
||||
self.timeout_secs = timeout_secs;
|
||||
self
|
||||
}
|
||||
|
||||
/// Collect all `system` role messages, concatenate their content,
|
||||
/// and prepend to the first `user` message. Drop all system messages.
|
||||
/// Used for providers (e.g. MiniMax) that reject `role: system`.
|
||||
@ -205,6 +214,7 @@ impl OpenAiCompatibleProvider {
|
||||
}
|
||||
|
||||
fn http_client(&self) -> Client {
|
||||
let timeout = self.timeout_secs;
|
||||
if let Some(ua) = self.user_agent.as_deref() {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(value) = HeaderValue::from_str(ua) {
|
||||
@ -212,7 +222,7 @@ impl OpenAiCompatibleProvider {
|
||||
}
|
||||
|
||||
let builder = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.timeout(std::time::Duration::from_secs(timeout))
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.default_headers(headers);
|
||||
let builder =
|
||||
@ -224,7 +234,7 @@ impl OpenAiCompatibleProvider {
|
||||
});
|
||||
}
|
||||
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.compatible", 120, 10)
|
||||
crate::config::build_runtime_proxy_client_with_timeouts("provider.compatible", timeout, 10)
|
||||
}
|
||||
|
||||
/// Build the full URL for chat completions, detecting if base_url already includes the path.
|
||||
@ -2164,6 +2174,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_completions_url_opencode_go() {
|
||||
// OpenCode Go uses /zen/go/v1 base path
|
||||
let p = make_provider("opencode-go", "https://opencode.ai/zen/go/v1", None);
|
||||
assert_eq!(
|
||||
p.chat_completions_url(),
|
||||
"https://opencode.ai/zen/go/v1/chat/completions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_native_response_preserves_tool_call_id() {
|
||||
let message = ResponseMessage {
|
||||
@ -2889,4 +2909,16 @@ mod tests {
|
||||
);
|
||||
assert!(json.contains("thinking..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_timeout_is_120s() {
|
||||
let p = make_provider("test", "https://example.com", None);
|
||||
assert_eq!(p.timeout_secs, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_timeout_secs_overrides_default() {
|
||||
let p = make_provider("test", "https://example.com", None).with_timeout_secs(300);
|
||||
assert_eq!(p.timeout_secs, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
//! in [`create_provider_with_url`]. See `AGENTS.md` §7.1 for the full change playbook.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod azure_openai;
|
||||
pub mod bedrock;
|
||||
pub mod compatible;
|
||||
pub mod copilot;
|
||||
@ -676,6 +677,9 @@ pub struct ProviderRuntimeOptions {
|
||||
pub zeroclaw_dir: Option<PathBuf>,
|
||||
pub secrets_encrypt: bool,
|
||||
pub reasoning_enabled: Option<bool>,
|
||||
/// HTTP request timeout in seconds for LLM provider API calls.
|
||||
/// `None` uses the provider's built-in default (120s for compatible providers).
|
||||
pub provider_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ProviderRuntimeOptions {
|
||||
@ -686,6 +690,7 @@ impl Default for ProviderRuntimeOptions {
|
||||
zeroclaw_dir: None,
|
||||
secrets_encrypt: true,
|
||||
reasoning_enabled: None,
|
||||
provider_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -839,6 +844,7 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
|
||||
"nvidia" | "nvidia-nim" | "build.nvidia.com" => vec!["NVIDIA_API_KEY"],
|
||||
"synthetic" => vec!["SYNTHETIC_API_KEY"],
|
||||
"opencode" | "opencode-zen" => vec!["OPENCODE_API_KEY"],
|
||||
"opencode-go" => vec!["OPENCODE_GO_API_KEY"],
|
||||
"vercel" | "vercel-ai" => vec!["VERCEL_API_KEY"],
|
||||
"cloudflare" | "cloudflare-ai" => vec!["CLOUDFLARE_API_KEY"],
|
||||
"ovhcloud" | "ovh" => vec!["OVH_AI_ENDPOINTS_ACCESS_TOKEN"],
|
||||
@ -848,6 +854,7 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) ->
|
||||
"vllm" => vec!["VLLM_API_KEY"],
|
||||
"osaurus" => vec!["OSAURUS_API_KEY"],
|
||||
"telnyx" => vec!["TELNYX_API_KEY"],
|
||||
"azure_openai" | "azure-openai" | "azure" => vec!["AZURE_OPENAI_API_KEY"],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
@ -990,6 +997,18 @@ fn create_provider_with_url_and_options(
|
||||
api_url: Option<&str>,
|
||||
options: &ProviderRuntimeOptions,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
// Closure to optionally apply the configured provider timeout to
|
||||
// OpenAI-compatible providers before boxing them as trait objects.
|
||||
let compat = {
|
||||
let timeout = options.provider_timeout_secs;
|
||||
move |p: OpenAiCompatibleProvider| -> Box<dyn Provider> {
|
||||
match timeout {
|
||||
Some(t) => Box::new(p.with_timeout_secs(t)),
|
||||
None => Box::new(p),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
|
||||
|
||||
// Resolve credential and break static-analysis taint chain from the
|
||||
@ -1063,28 +1082,28 @@ fn create_provider_with_url_and_options(
|
||||
"telnyx" => Ok(Box::new(telnyx::TelnyxProvider::new(key))),
|
||||
|
||||
// ── OpenAI-compatible providers ──────────────────────
|
||||
"venice" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"venice" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Venice", "https://api.venice.ai", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"vercel" | "vercel-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"vercel" | "vercel-ai" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Vercel AI Gateway",
|
||||
VERCEL_AI_GATEWAY_BASE_URL,
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
"cloudflare" | "cloudflare-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"cloudflare" | "cloudflare-ai" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Cloudflare AI Gateway",
|
||||
"https://gateway.ai.cloudflare.com/v1",
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
name if moonshot_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
name if moonshot_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Moonshot",
|
||||
moonshot_base_url(name).expect("checked in guard"),
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
"kimi-code" | "kimi_coding" | "kimi_for_coding" => Ok(Box::new(
|
||||
"kimi-code" | "kimi_coding" | "kimi_for_coding" => Ok(compat(
|
||||
OpenAiCompatibleProvider::new_with_user_agent(
|
||||
"Kimi Code",
|
||||
"https://api.kimi.com/coding/v1",
|
||||
@ -1093,27 +1112,30 @@ fn create_provider_with_url_and_options(
|
||||
"KimiCLI/0.77",
|
||||
),
|
||||
)),
|
||||
"synthetic" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"synthetic" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Synthetic", "https://api.synthetic.new/openai/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"opencode" | "opencode-zen" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"opencode" | "opencode-zen" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"OpenCode Zen", "https://opencode.ai/zen/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
name if zai_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"opencode-go" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"OpenCode Go", "https://opencode.ai/zen/go/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
name if zai_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Z.AI",
|
||||
zai_base_url(name).expect("checked in guard"),
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
name if glm_base_url(name).is_some() => {
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new_no_responses_fallback(
|
||||
Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
|
||||
"GLM",
|
||||
glm_base_url(name).expect("checked in guard"),
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
)))
|
||||
}
|
||||
name if minimax_base_url(name).is_some() => Ok(Box::new(
|
||||
name if minimax_base_url(name).is_some() => Ok(compat(
|
||||
OpenAiCompatibleProvider::new_merge_system_into_user(
|
||||
"MiniMax",
|
||||
minimax_base_url(name).expect("checked in guard"),
|
||||
@ -1121,6 +1143,19 @@ fn create_provider_with_url_and_options(
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
)),
|
||||
"azure_openai" | "azure-openai" | "azure" => {
|
||||
let resource = std::env::var("AZURE_OPENAI_RESOURCE")
|
||||
.unwrap_or_else(|_| "my-resource".to_string());
|
||||
let deployment = std::env::var("AZURE_OPENAI_DEPLOYMENT")
|
||||
.unwrap_or_else(|_| "gpt-4o".to_string());
|
||||
let api_version = std::env::var("AZURE_OPENAI_API_VERSION").ok();
|
||||
Ok(Box::new(azure_openai::AzureOpenAiProvider::new(
|
||||
key,
|
||||
&resource,
|
||||
&deployment,
|
||||
api_version.as_deref(),
|
||||
)))
|
||||
}
|
||||
"bedrock" | "aws-bedrock" => Ok(Box::new(bedrock::BedrockProvider::new())),
|
||||
name if is_qwen_oauth_alias(name) => {
|
||||
let base_url = api_url
|
||||
@ -1130,7 +1165,7 @@ fn create_provider_with_url_and_options(
|
||||
.or_else(|| qwen_oauth_context.as_ref().and_then(|context| context.base_url.clone()))
|
||||
.unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
|
||||
|
||||
Ok(Box::new(
|
||||
Ok(compat(
|
||||
OpenAiCompatibleProvider::new_with_user_agent_and_vision(
|
||||
"Qwen Code",
|
||||
&base_url,
|
||||
@ -1140,16 +1175,16 @@ fn create_provider_with_url_and_options(
|
||||
true,
|
||||
)))
|
||||
}
|
||||
name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
name if is_qianfan_alias(name) => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
|
||||
))),
|
||||
name if is_doubao_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
name if is_doubao_alias(name) => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Doubao",
|
||||
"https://ark.cn-beijing.volces.com/api/v3",
|
||||
key,
|
||||
AuthStyle::Bearer,
|
||||
))),
|
||||
name if qwen_base_url(name).is_some() => Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
|
||||
name if qwen_base_url(name).is_some() => Ok(compat(OpenAiCompatibleProvider::new_with_vision(
|
||||
"Qwen",
|
||||
qwen_base_url(name).expect("checked in guard"),
|
||||
key,
|
||||
@ -1158,31 +1193,31 @@ fn create_provider_with_url_and_options(
|
||||
))),
|
||||
|
||||
// ── Extended ecosystem (community favorites) ─────────
|
||||
"groq" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"groq" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Groq", "https://api.groq.com/openai/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"mistral" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"mistral" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Mistral", "https://api.mistral.ai/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"xai" | "grok" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"xai" | "grok" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"xAI", "https://api.x.ai", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"deepseek" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"deepseek" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"DeepSeek", "https://api.deepseek.com", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"together" | "together-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"together" | "together-ai" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Together AI", "https://api.together.xyz", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"fireworks" | "fireworks-ai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"fireworks" | "fireworks-ai" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Fireworks AI", "https://api.fireworks.ai/inference/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"novita" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"novita" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Novita AI", "https://api.novita.ai/openai", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"perplexity" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"perplexity" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Perplexity", "https://api.perplexity.ai", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"cohere" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"cohere" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
|
||||
@ -1191,7 +1226,7 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("lm-studio");
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"LM Studio",
|
||||
"http://localhost:1234/v1",
|
||||
Some(lm_studio_key),
|
||||
@ -1207,7 +1242,7 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("llama.cpp");
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"llama.cpp",
|
||||
base_url,
|
||||
Some(llama_cpp_key),
|
||||
@ -1219,7 +1254,7 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("http://localhost:30000/v1");
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"SGLang",
|
||||
base_url,
|
||||
key,
|
||||
@ -1231,7 +1266,7 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("http://localhost:8000/v1");
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"vLLM",
|
||||
base_url,
|
||||
key,
|
||||
@ -1247,14 +1282,14 @@ fn create_provider_with_url_and_options(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("osaurus");
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Osaurus",
|
||||
base_url,
|
||||
Some(osaurus_key),
|
||||
AuthStyle::Bearer,
|
||||
)))
|
||||
}
|
||||
"nvidia" | "nvidia-nim" | "build.nvidia.com" => Ok(Box::new(
|
||||
"nvidia" | "nvidia-nim" | "build.nvidia.com" => Ok(compat(
|
||||
OpenAiCompatibleProvider::new_no_responses_fallback(
|
||||
"NVIDIA NIM",
|
||||
"https://integrate.api.nvidia.com/v1",
|
||||
@ -1264,7 +1299,7 @@ fn create_provider_with_url_and_options(
|
||||
)),
|
||||
|
||||
// ── AI inference routers ─────────────────────────────
|
||||
"astrai" => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||
"astrai" => Ok(compat(OpenAiCompatibleProvider::new(
|
||||
"Astrai", "https://as-trai.com/v1", key, AuthStyle::Bearer,
|
||||
))),
|
||||
|
||||
@ -1282,7 +1317,7 @@ fn create_provider_with_url_and_options(
|
||||
"Custom provider",
|
||||
"custom:https://your-api.com",
|
||||
)?;
|
||||
Ok(Box::new(OpenAiCompatibleProvider::new_with_vision(
|
||||
Ok(compat(OpenAiCompatibleProvider::new_with_vision(
|
||||
"Custom",
|
||||
&base_url,
|
||||
key,
|
||||
@ -1608,6 +1643,12 @@ pub fn list_providers() -> Vec<ProviderInfo> {
|
||||
aliases: &["opencode-zen"],
|
||||
local: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "opencode-go",
|
||||
display_name: "OpenCode Go",
|
||||
aliases: &[],
|
||||
local: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "zai",
|
||||
display_name: "Z.AI",
|
||||
@ -2141,6 +2182,22 @@ mod tests {
|
||||
assert!(create_provider("opencode-zen", Some("key")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_opencode_go() {
|
||||
assert!(create_provider("opencode-go", Some("key")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_provider_credential_opencode_go_env() {
|
||||
let _env_lock = env_lock();
|
||||
let _provider_guard = EnvGuard::set("OPENCODE_GO_API_KEY", Some("go-test-key"));
|
||||
let _generic_guard = EnvGuard::set("API_KEY", None);
|
||||
let _zeroclaw_guard = EnvGuard::set("ZEROCLAW_API_KEY", None);
|
||||
|
||||
let resolved = resolve_provider_credential("opencode-go", None);
|
||||
assert_eq!(resolved.as_deref(), Some("go-test-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_zai() {
|
||||
assert!(create_provider("zai", Some("key")).is_ok());
|
||||
@ -2663,6 +2720,7 @@ mod tests {
|
||||
"kimi-code",
|
||||
"synthetic",
|
||||
"opencode",
|
||||
"opencode-go",
|
||||
"zai",
|
||||
"zai-cn",
|
||||
"glm",
|
||||
|
||||
@ -169,13 +169,66 @@ impl OllamaProvider {
|
||||
}
|
||||
|
||||
fn normalize_response_text(content: String) -> Option<String> {
|
||||
if content.trim().is_empty() {
|
||||
let stripped = Self::strip_think_tags(&content);
|
||||
if stripped.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(content)
|
||||
Some(stripped)
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `<think>...</think>` blocks from model output.
|
||||
/// Qwen and other reasoning models may embed chain-of-thought inline
|
||||
/// in the `content` field using `<think>` tags. These must be stripped
|
||||
/// before returning text to the user or parsing for tool calls.
|
||||
fn strip_think_tags(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
loop {
|
||||
if let Some(start) = rest.find("<think>") {
|
||||
result.push_str(&rest[..start]);
|
||||
if let Some(end) = rest[start..].find("</think>") {
|
||||
rest = &rest[start + end + "</think>".len()..];
|
||||
} else {
|
||||
// Unclosed tag: drop the rest to avoid leaking partial reasoning.
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
result.push_str(rest);
|
||||
break;
|
||||
}
|
||||
}
|
||||
result.trim().to_string()
|
||||
}
|
||||
|
||||
/// Derive the effective text content from a response, stripping `<think>` tags
|
||||
/// and falling back to the `thinking` field when `content` is empty after
|
||||
/// stripping. This ensures that tool-call XML tags embedded alongside (or
|
||||
/// after) thinking blocks are preserved for downstream parsing.
|
||||
fn effective_content(content: &str, thinking: Option<&str>) -> Option<String> {
|
||||
// First try the content field with think tags stripped.
|
||||
let stripped = Self::strip_think_tags(content);
|
||||
if !stripped.trim().is_empty() {
|
||||
return Some(stripped);
|
||||
}
|
||||
|
||||
// Content was empty or only thinking — check the thinking field.
|
||||
// Some models (Qwen) put the full output including tool-call XML in
|
||||
// the thinking field when `think: true` is set.
|
||||
if let Some(thinking) = thinking.map(str::trim).filter(|t| !t.is_empty()) {
|
||||
let stripped_thinking = Self::strip_think_tags(thinking);
|
||||
if !stripped_thinking.trim().is_empty() {
|
||||
tracing::debug!(
|
||||
"Ollama: using thinking field as effective content ({} chars)",
|
||||
stripped_thinking.len()
|
||||
);
|
||||
return Some(stripped_thinking);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn fallback_text_for_empty_content(model: &str, thinking: Option<&str>) -> String {
|
||||
if let Some(thinking) = thinking.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let thinking_log_excerpt: String = thinking.chars().take(100).collect();
|
||||
@ -537,9 +590,11 @@ impl Provider for OllamaProvider {
|
||||
return Ok(self.format_tool_calls_for_loop(&response.message.tool_calls));
|
||||
}
|
||||
|
||||
// Plain text response
|
||||
let content = response.message.content;
|
||||
if let Some(content) = Self::normalize_response_text(content) {
|
||||
// Plain text response — strip <think> tags and fall back to thinking field.
|
||||
if let Some(content) = Self::effective_content(
|
||||
&response.message.content,
|
||||
response.message.thinking.as_deref(),
|
||||
) {
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
@ -578,9 +633,11 @@ impl Provider for OllamaProvider {
|
||||
return Ok(self.format_tool_calls_for_loop(&response.message.tool_calls));
|
||||
}
|
||||
|
||||
// Plain text response
|
||||
let content = response.message.content;
|
||||
if let Some(content) = Self::normalize_response_text(content) {
|
||||
// Plain text response — strip <think> tags and fall back to thinking field.
|
||||
if let Some(content) = Self::effective_content(
|
||||
&response.message.content,
|
||||
response.message.thinking.as_deref(),
|
||||
) {
|
||||
return Ok(content);
|
||||
}
|
||||
|
||||
@ -652,9 +709,15 @@ impl Provider for OllamaProvider {
|
||||
});
|
||||
}
|
||||
|
||||
// Plain text response.
|
||||
let content = response.message.content;
|
||||
let text = if let Some(content) = Self::normalize_response_text(content) {
|
||||
// No native tool calls — use the effective content (content with
|
||||
// `<think>` tags stripped, falling back to thinking field).
|
||||
// The loop_.rs `parse_tool_calls` will extract any XML-style tool
|
||||
// calls from the text, so preserve `<tool_call>` tags here.
|
||||
let effective = Self::effective_content(
|
||||
&response.message.content,
|
||||
response.message.thinking.as_deref(),
|
||||
);
|
||||
let text = if let Some(content) = effective {
|
||||
content
|
||||
} else {
|
||||
Self::fallback_text_for_empty_content(
|
||||
@ -868,7 +931,25 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
OllamaProvider::normalize_response_text(" hello ".to_string()),
|
||||
Some(" hello ".to_string())
|
||||
Some("hello".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_response_text_strips_think_tags() {
|
||||
assert_eq!(
|
||||
OllamaProvider::normalize_response_text("<think>reasoning</think> hello".to_string()),
|
||||
Some("hello".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_response_text_rejects_think_only_content() {
|
||||
assert_eq!(
|
||||
OllamaProvider::normalize_response_text(
|
||||
"<think>only thinking here</think>".to_string()
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
@ -1069,4 +1150,129 @@ mod tests {
|
||||
assert!(resp.prompt_eval_count.is_none());
|
||||
assert!(resp.eval_count.is_none());
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// <think> tag stripping tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_removes_single_block() {
|
||||
let input = "<think>internal reasoning</think>Hello world";
|
||||
assert_eq!(OllamaProvider::strip_think_tags(input), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_removes_multiple_blocks() {
|
||||
let input = "<think>first</think>A<think>second</think>B";
|
||||
assert_eq!(OllamaProvider::strip_think_tags(input), "AB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_handles_unclosed_block() {
|
||||
let input = "visible<think>hidden tail";
|
||||
assert_eq!(OllamaProvider::strip_think_tags(input), "visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_preserves_text_without_tags() {
|
||||
let input = "plain text response";
|
||||
assert_eq!(
|
||||
OllamaProvider::strip_think_tags(input),
|
||||
"plain text response"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_think_tags_returns_empty_for_think_only() {
|
||||
let input = "<think>only thinking</think>";
|
||||
assert_eq!(OllamaProvider::strip_think_tags(input), "");
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// effective_content tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn effective_content_strips_think_and_returns_rest() {
|
||||
let result = OllamaProvider::effective_content(
|
||||
"<think>reasoning</think>\n<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call>",
|
||||
None,
|
||||
);
|
||||
assert!(result.is_some());
|
||||
let text = result.unwrap();
|
||||
assert!(text.contains("<tool_call>"));
|
||||
assert!(!text.contains("<think>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_content_falls_back_to_thinking_field() {
|
||||
let result = OllamaProvider::effective_content(
|
||||
"",
|
||||
Some(
|
||||
"<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}</tool_call>",
|
||||
),
|
||||
);
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().contains("<tool_call>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_content_returns_none_when_both_empty() {
|
||||
assert!(OllamaProvider::effective_content("", None).is_none());
|
||||
assert!(OllamaProvider::effective_content("", Some("")).is_none());
|
||||
assert!(OllamaProvider::effective_content(
|
||||
"<think>only thinking</think>",
|
||||
Some("<think>also only thinking</think>")
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_content_prefers_content_over_thinking() {
|
||||
let result = OllamaProvider::effective_content("content text", Some("thinking text"));
|
||||
assert_eq!(result, Some("content text".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_content_uses_thinking_when_content_is_think_only() {
|
||||
let result = OllamaProvider::effective_content(
|
||||
"<think>just reasoning</think>",
|
||||
Some("actual useful text from thinking field"),
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some("actual useful text from thinking field".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Qwen tool-call regression scenario tests
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
#[test]
|
||||
fn qwen_think_with_tool_call_in_content_preserved() {
|
||||
// Qwen produces <think> tags followed by <tool_call> in content,
|
||||
// with no structured tool_calls. The <tool_call> tags must survive
|
||||
// for downstream parse_tool_calls to extract them.
|
||||
let content = "<think>I should list files</think>\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}\n</tool_call>";
|
||||
let result = OllamaProvider::effective_content(content, None);
|
||||
assert!(result.is_some());
|
||||
let text = result.unwrap();
|
||||
assert!(text.contains("<tool_call>"));
|
||||
assert!(text.contains("shell"));
|
||||
assert!(!text.contains("<think>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qwen_thinking_field_with_tool_call_xml_extracted() {
|
||||
// When think=true, Ollama separates thinking, but Qwen may put tool
|
||||
// call XML in the thinking field with empty content.
|
||||
let content = "";
|
||||
let thinking = "I need to check the date\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}\n</tool_call>";
|
||||
let result = OllamaProvider::effective_content(content, Some(thinking));
|
||||
assert!(result.is_some());
|
||||
let text = result.unwrap();
|
||||
assert!(text.contains("<tool_call>"));
|
||||
assert!(text.contains("date"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1017,6 +1017,7 @@ data: [DONE]
|
||||
secrets_encrypt: false,
|
||||
auth_profile_override: None,
|
||||
reasoning_enabled: None,
|
||||
provider_timeout_secs: None,
|
||||
};
|
||||
let provider =
|
||||
OpenAiCodexProvider::new(&options, None).expect("provider should initialize");
|
||||
|
||||
@ -7,8 +7,12 @@
|
||||
//! Contributed from RustyClaw (MIT licensed).
|
||||
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Minimum token length considered for high-entropy detection.
|
||||
const ENTROPY_TOKEN_MIN_LEN: usize = 24;
|
||||
|
||||
/// Result of leak detection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LeakResult {
|
||||
@ -61,6 +65,7 @@ impl LeakDetector {
|
||||
self.check_private_keys(content, &mut patterns, &mut redacted);
|
||||
self.check_jwt_tokens(content, &mut patterns, &mut redacted);
|
||||
self.check_database_urls(content, &mut patterns, &mut redacted);
|
||||
self.check_high_entropy_tokens(content, &mut patterns, &mut redacted);
|
||||
|
||||
if patterns.is_empty() {
|
||||
LeakResult::Clean
|
||||
@ -288,6 +293,72 @@ impl LeakDetector {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for high-entropy tokens that may be leaked credentials.
|
||||
///
|
||||
/// Extracts candidate tokens from content (after stripping URLs to avoid
|
||||
/// false-positives on path segments) and flags any that exceed the Shannon
|
||||
/// entropy threshold derived from the detector's sensitivity.
|
||||
fn check_high_entropy_tokens(
|
||||
&self,
|
||||
content: &str,
|
||||
patterns: &mut Vec<String>,
|
||||
redacted: &mut String,
|
||||
) {
|
||||
// Entropy threshold scales with sensitivity: at 0.7 this is ~4.37.
|
||||
let entropy_threshold = 3.5 + self.sensitivity * 1.25;
|
||||
|
||||
// Strip URLs before extracting tokens so that path segments like
|
||||
// "org/documents/2024-report-a1b2c3d4e5f6g7h8i9j0" are not mistaken
|
||||
// for high-entropy credentials.
|
||||
static URL_PATTERN: OnceLock<Regex> = OnceLock::new();
|
||||
let url_re = URL_PATTERN.get_or_init(|| Regex::new(r"https?://\S+").unwrap());
|
||||
let content_without_urls = url_re.replace_all(content, "");
|
||||
|
||||
let tokens = extract_candidate_tokens(&content_without_urls);
|
||||
|
||||
for token in tokens {
|
||||
if token.len() >= ENTROPY_TOKEN_MIN_LEN {
|
||||
let entropy = shannon_entropy(token);
|
||||
if entropy >= entropy_threshold && has_mixed_alpha_digit(token) {
|
||||
patterns.push("High-entropy token".to_string());
|
||||
*redacted = redacted.replace(token, "[REDACTED_HIGH_ENTROPY_TOKEN]");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract candidate tokens by splitting on characters outside the
|
||||
/// alphanumeric + common credential character set.
|
||||
fn extract_candidate_tokens(content: &str) -> Vec<&str> {
|
||||
content
|
||||
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-' && c != '+' && c != '/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compute Shannon entropy (bits per character) for the given string.
|
||||
fn shannon_entropy(s: &str) -> f64 {
|
||||
let len = s.len() as f64;
|
||||
if len == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut freq: HashMap<u8, usize> = HashMap::new();
|
||||
for &b in s.as_bytes() {
|
||||
*freq.entry(b).or_insert(0) += 1;
|
||||
}
|
||||
freq.values().fold(0.0, |acc, &count| {
|
||||
let p = count as f64 / len;
|
||||
acc - p * p.log2()
|
||||
})
|
||||
}
|
||||
|
||||
/// Check whether a token contains both alphabetic and digit characters.
|
||||
fn has_mixed_alpha_digit(s: &str) -> bool {
|
||||
let has_alpha = s.bytes().any(|b| b.is_ascii_alphabetic());
|
||||
let has_digit = s.bytes().any(|b| b.is_ascii_digit());
|
||||
has_alpha && has_digit
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -381,4 +452,87 @@ MIIEowIBAAKCAQEA0ZPr5JeyVDonXsKhfq...
|
||||
// Low sensitivity should not flag generic secrets
|
||||
assert!(matches!(result, LeakResult::Clean));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_path_segments_not_flagged() {
|
||||
let detector = LeakDetector::new();
|
||||
// URL with a long mixed-alphanumeric path segment that would previously
|
||||
// false-positive as a high-entropy token.
|
||||
let content =
|
||||
"See https://example.org/documents/2024-report-a1b2c3d4e5f6g7h8i9j0.pdf for details";
|
||||
let result = detector.scan(content);
|
||||
assert!(
|
||||
matches!(result, LeakResult::Clean),
|
||||
"URL path segments should not trigger high-entropy detection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_with_long_path_not_redacted() {
|
||||
let detector = LeakDetector::new();
|
||||
let content = "Reference: https://gov.example.com/publications/research/2024-annual-fiscal-policy-review-9a8b7c6d5e4f3g2h1i0j.html";
|
||||
let result = detector.scan(content);
|
||||
assert!(
|
||||
matches!(result, LeakResult::Clean),
|
||||
"Long URL paths should not be redacted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_high_entropy_token_outside_url() {
|
||||
let detector = LeakDetector::new();
|
||||
// A standalone high-entropy token (not in a URL) should still be detected.
|
||||
let content = "Found credential: aB3xK9mW2pQ7vL4nR8sT1yU6hD0jF5cG";
|
||||
let result = detector.scan(content);
|
||||
match result {
|
||||
LeakResult::Detected { patterns, redacted } => {
|
||||
assert!(patterns.iter().any(|p| p.contains("High-entropy")));
|
||||
assert!(redacted.contains("[REDACTED_HIGH_ENTROPY_TOKEN]"));
|
||||
}
|
||||
LeakResult::Clean => panic!("Should detect high-entropy token"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_sensitivity_raises_entropy_threshold() {
|
||||
let detector = LeakDetector::with_sensitivity(0.3);
|
||||
// At low sensitivity the entropy threshold is higher (3.5 + 0.3*1.25 = 3.875).
|
||||
// A repetitive mixed token has low entropy and should not be flagged.
|
||||
let content = "token found: ab12ab12ab12ab12ab12ab12ab12ab12";
|
||||
let result = detector.scan(content);
|
||||
assert!(
|
||||
matches!(result, LeakResult::Clean),
|
||||
"Low-entropy repetitive tokens should not be flagged"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_candidate_tokens_splits_correctly() {
|
||||
let tokens = extract_candidate_tokens("foo.bar:baz qux-quux key=val");
|
||||
assert!(tokens.contains(&"foo"));
|
||||
assert!(tokens.contains(&"bar"));
|
||||
assert!(tokens.contains(&"baz"));
|
||||
assert!(tokens.contains(&"qux-quux"));
|
||||
// '=' is a delimiter, not part of tokens
|
||||
assert!(tokens.contains(&"key"));
|
||||
assert!(tokens.contains(&"val"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shannon_entropy_empty_string() {
|
||||
assert_eq!(shannon_entropy(""), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shannon_entropy_single_char() {
|
||||
// All same characters: entropy = 0
|
||||
assert_eq!(shannon_entropy("aaaa"), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shannon_entropy_two_equal_chars() {
|
||||
// "ab" repeated: entropy = 1.0 bit
|
||||
let e = shannon_entropy("abab");
|
||||
assert!((e - 1.0).abs() < 0.001);
|
||||
}
|
||||
}
|
||||
|
||||
@ -922,9 +922,28 @@ impl SecurityPolicy {
|
||||
// Expand "~" for consistent matching with forbidden paths and allowlists.
|
||||
let expanded_path = expand_user_path(path);
|
||||
|
||||
// Block absolute paths when workspace_only is set
|
||||
if self.workspace_only && expanded_path.is_absolute() {
|
||||
return false;
|
||||
// When workspace_only is set and the path is absolute, only allow it
|
||||
// if it falls within the workspace directory or an explicit allowed
|
||||
// root. The workspace/allowed-root check runs BEFORE the forbidden
|
||||
// prefix list so that workspace paths under broad defaults like
|
||||
// "/home" are not rejected. This mirrors the priority order in
|
||||
// `is_resolved_path_allowed`. See #2880.
|
||||
if expanded_path.is_absolute() {
|
||||
let in_workspace = expanded_path.starts_with(&self.workspace_dir);
|
||||
let in_allowed_root = self
|
||||
.allowed_roots
|
||||
.iter()
|
||||
.any(|root| expanded_path.starts_with(root));
|
||||
|
||||
if in_workspace || in_allowed_root {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Absolute path outside workspace/allowed roots — block when
|
||||
// workspace_only, or fall through to forbidden-prefix check.
|
||||
if self.workspace_only {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Block forbidden paths using path-component-aware matching
|
||||
@ -1384,6 +1403,37 @@ mod tests {
|
||||
assert!(!p.is_path_allowed("/tmp/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_inside_workspace_allowed_when_workspace_only() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_dir: PathBuf::from("/home/user/.zeroclaw/workspace"),
|
||||
workspace_only: true,
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
// Absolute path inside workspace should be allowed
|
||||
assert!(p.is_path_allowed("/home/user/.zeroclaw/workspace/images/example.png"));
|
||||
assert!(p.is_path_allowed("/home/user/.zeroclaw/workspace/file.txt"));
|
||||
// Absolute path outside workspace should still be blocked
|
||||
assert!(!p.is_path_allowed("/home/user/other/file.txt"));
|
||||
assert!(!p.is_path_allowed("/tmp/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_path_in_allowed_root_permitted_when_workspace_only() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_dir: PathBuf::from("/home/user/.zeroclaw/workspace"),
|
||||
workspace_only: true,
|
||||
allowed_roots: vec![PathBuf::from("/home/user/.zeroclaw/shared")],
|
||||
..SecurityPolicy::default()
|
||||
};
|
||||
// Path in allowed root should be permitted
|
||||
assert!(p.is_path_allowed("/home/user/.zeroclaw/shared/data.txt"));
|
||||
// Path in workspace should still be permitted
|
||||
assert!(p.is_path_allowed("/home/user/.zeroclaw/workspace/file.txt"));
|
||||
// Path outside both should still be blocked
|
||||
assert!(!p.is_path_allowed("/home/user/other/file.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_paths_allowed_when_not_workspace_only() {
|
||||
let p = SecurityPolicy {
|
||||
@ -2122,7 +2172,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checklist_workspace_only_blocks_all_absolute() {
|
||||
fn checklist_workspace_only_blocks_absolute_outside_workspace() {
|
||||
let p = SecurityPolicy {
|
||||
workspace_only: true,
|
||||
..SecurityPolicy::default()
|
||||
|
||||
@ -184,7 +184,7 @@ impl Tool for CronAddTool {
|
||||
return Ok(blocked);
|
||||
}
|
||||
|
||||
cron::add_shell_job(&self.config, name, schedule, command)
|
||||
cron::add_shell_job_with_approval(&self.config, name, schedule, command, approved)
|
||||
}
|
||||
JobType::Agent => {
|
||||
let prompt = match args.get("prompt").and_then(serde_json::Value::as_str) {
|
||||
|
||||
@ -166,10 +166,10 @@ mod tests {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let job = cron::add_job(&config, "*/5 * * * *", "echo ok").unwrap();
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "echo ok").unwrap();
|
||||
let tool = CronRemoveTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool.execute(json!({"job_id": job.id})).await.unwrap();
|
||||
|
||||
@ -211,10 +211,10 @@ mod tests {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let job = cron::add_job(&config, "*/5 * * * *", "echo run-now").unwrap();
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "echo run-now").unwrap();
|
||||
let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool.execute(json!({ "job_id": job.id })).await.unwrap();
|
||||
@ -234,21 +234,27 @@ mod tests {
|
||||
config.autonomy.allowed_commands = vec!["touch".into()];
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "touch cron-run-approval").unwrap();
|
||||
// Create with explicit approval so the job persists for the run test.
|
||||
let job = cron::add_shell_job_with_approval(
|
||||
&cfg,
|
||||
None,
|
||||
cron::Schedule::Cron {
|
||||
expr: "*/5 * * * *".into(),
|
||||
tz: None,
|
||||
},
|
||||
"touch cron-run-approval",
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let tool = CronRunTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
// Without approval, the tool-level policy check blocks medium-risk commands.
|
||||
let denied = tool.execute(json!({ "job_id": job.id })).await.unwrap();
|
||||
assert!(!denied.success);
|
||||
assert!(denied
|
||||
.error
|
||||
.unwrap_or_default()
|
||||
.contains("explicit approval"));
|
||||
|
||||
let approved = tool
|
||||
.execute(json!({ "job_id": job.id, "approved": true }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(approved.success, "{:?}", approved.error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -119,21 +119,11 @@ impl Tool for CronUpdateTool {
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(command) = &patch.command {
|
||||
if let Err(reason) = self.security.validate_command_execution(command, approved) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(reason),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(blocked) = self.enforce_mutation_allowed("cron_update") {
|
||||
return Ok(blocked);
|
||||
}
|
||||
|
||||
match cron::update_job(&self.config, job_id, patch) {
|
||||
match cron::update_shell_job_with_approval(&self.config, job_id, patch, approved) {
|
||||
Ok(job) => Ok(ToolResult {
|
||||
success: true,
|
||||
output: serde_json::to_string_pretty(&job)?,
|
||||
@ -228,10 +218,10 @@ mod tests {
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..Config::default()
|
||||
};
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
std::fs::create_dir_all(&config.workspace_dir).unwrap();
|
||||
let job = cron::add_job(&config, "*/5 * * * *", "echo ok").unwrap();
|
||||
config.autonomy.level = AutonomyLevel::ReadOnly;
|
||||
let cfg = Arc::new(config);
|
||||
let job = cron::add_job(&cfg, "*/5 * * * *", "echo ok").unwrap();
|
||||
let tool = CronUpdateTool::new(cfg.clone(), test_security(&cfg));
|
||||
|
||||
let result = tool
|
||||
|
||||
@ -411,6 +411,7 @@ impl DelegateTool {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
&[],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
@ -289,11 +289,13 @@ pub fn all_tools_with_runtime(
|
||||
|
||||
// Web search tool (enabled by default for GLM and other models)
|
||||
if root_config.web_search.enabled {
|
||||
tool_arcs.push(Arc::new(WebSearchTool::new(
|
||||
tool_arcs.push(Arc::new(WebSearchTool::new_with_config(
|
||||
root_config.web_search.provider.clone(),
|
||||
root_config.web_search.brave_api_key.clone(),
|
||||
root_config.web_search.max_results,
|
||||
root_config.web_search.timeout_secs,
|
||||
root_config.config_path.clone(),
|
||||
root_config.secrets.encrypt,
|
||||
)));
|
||||
}
|
||||
|
||||
@ -338,6 +340,7 @@ pub fn all_tools_with_runtime(
|
||||
.map(std::path::PathBuf::from),
|
||||
secrets_encrypt: root_config.secrets.encrypt,
|
||||
reasoning_enabled: root_config.runtime.reasoning_enabled,
|
||||
provider_timeout_secs: Some(root_config.provider_timeout_secs),
|
||||
},
|
||||
)
|
||||
.with_parent_tools(parent_tools)
|
||||
|
||||
@ -253,14 +253,6 @@ impl ScheduleTool {
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing or empty 'command' parameter"))?;
|
||||
|
||||
if let Err(reason) = self.security.validate_command_execution(command, approved) {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(reason),
|
||||
});
|
||||
}
|
||||
|
||||
let expression = args.get("expression").and_then(|value| value.as_str());
|
||||
let delay = args.get("delay").and_then(|value| value.as_str());
|
||||
let run_at = args.get("run_at").and_then(|value| value.as_str());
|
||||
@ -309,8 +301,28 @@ impl ScheduleTool {
|
||||
}
|
||||
}
|
||||
|
||||
// All job creation routes through validated cron helpers, which enforce
|
||||
// the full security policy (allowlist + risk gate) before persistence.
|
||||
if let Some(value) = expression {
|
||||
let job = cron::add_job(&self.config, value, command)?;
|
||||
let job = match cron::add_shell_job_with_approval(
|
||||
&self.config,
|
||||
None,
|
||||
cron::Schedule::Cron {
|
||||
expr: value.to_string(),
|
||||
tz: None,
|
||||
},
|
||||
command,
|
||||
approved,
|
||||
) {
|
||||
Ok(job) => job,
|
||||
Err(error) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!(
|
||||
@ -325,7 +337,16 @@ impl ScheduleTool {
|
||||
}
|
||||
|
||||
if let Some(value) = delay {
|
||||
let job = cron::add_once(&self.config, value, command)?;
|
||||
let job = match cron::add_once_validated(&self.config, value, command, approved) {
|
||||
Ok(job) => job,
|
||||
Err(error) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!(
|
||||
@ -343,7 +364,17 @@ impl ScheduleTool {
|
||||
.map_err(|error| anyhow::anyhow!("Invalid run_at timestamp: {error}"))?
|
||||
.with_timezone(&Utc);
|
||||
|
||||
let job = cron::add_once_at(&self.config, run_at_parsed, command)?;
|
||||
let job = match cron::add_once_at_validated(&self.config, run_at_parsed, command, approved)
|
||||
{
|
||||
Ok(job) => job,
|
||||
Err(error) => {
|
||||
return Ok(ToolResult {
|
||||
success: false,
|
||||
output: String::new(),
|
||||
error: Some(error.to_string()),
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(ToolResult {
|
||||
success: true,
|
||||
output: format!(
|
||||
|
||||
@ -2,15 +2,26 @@ use super::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Web search tool for searching the internet.
|
||||
/// Supports multiple providers: DuckDuckGo (free), Brave (requires API key).
|
||||
///
|
||||
/// The Brave API key is resolved lazily at execution time: if the boot-time key
|
||||
/// is missing or still encrypted, the tool re-reads `config.toml`, decrypts the
|
||||
/// `[web_search] brave_api_key` field, and uses the result. This ensures that
|
||||
/// keys set or rotated after boot, and encrypted keys, are correctly picked up.
|
||||
pub struct WebSearchTool {
|
||||
provider: String,
|
||||
brave_api_key: Option<String>,
|
||||
/// Boot-time key snapshot (may be `None` if not yet configured at startup).
|
||||
boot_brave_api_key: Option<String>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
/// Path to `config.toml` for lazy re-read of keys at execution time.
|
||||
config_path: PathBuf,
|
||||
/// Whether secret encryption is enabled (needed to create a `SecretStore`).
|
||||
secrets_encrypt: bool,
|
||||
}
|
||||
|
||||
impl WebSearchTool {
|
||||
@ -22,9 +33,85 @@ impl WebSearchTool {
|
||||
) -> Self {
|
||||
Self {
|
||||
provider: provider.trim().to_lowercase(),
|
||||
brave_api_key,
|
||||
boot_brave_api_key: brave_api_key,
|
||||
max_results: max_results.clamp(1, 10),
|
||||
timeout_secs: timeout_secs.max(1),
|
||||
config_path: PathBuf::new(),
|
||||
secrets_encrypt: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a `WebSearchTool` with config-reload and decryption support.
|
||||
///
|
||||
/// `config_path` is the path to `config.toml` so the tool can re-read the
|
||||
/// Brave API key at execution time. `secrets_encrypt` controls whether the
|
||||
/// key is decrypted via `SecretStore`.
|
||||
pub fn new_with_config(
|
||||
provider: String,
|
||||
brave_api_key: Option<String>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
config_path: PathBuf,
|
||||
secrets_encrypt: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider: provider.trim().to_lowercase(),
|
||||
boot_brave_api_key: brave_api_key,
|
||||
max_results: max_results.clamp(1, 10),
|
||||
timeout_secs: timeout_secs.max(1),
|
||||
config_path,
|
||||
secrets_encrypt,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Brave API key, preferring the boot-time value but falling
|
||||
/// back to a fresh config read + decryption when the boot-time value is
|
||||
/// absent.
|
||||
fn resolve_brave_api_key(&self) -> anyhow::Result<String> {
|
||||
// Fast path: boot-time key is present and usable (not an encrypted blob).
|
||||
if let Some(ref key) = self.boot_brave_api_key {
|
||||
if !key.is_empty() && !crate::security::SecretStore::is_encrypted(key) {
|
||||
return Ok(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: re-read config.toml to pick up keys set/rotated after boot.
|
||||
self.reload_brave_api_key()
|
||||
}
|
||||
|
||||
/// Re-read `config.toml` and decrypt `[web_search] brave_api_key`.
|
||||
fn reload_brave_api_key(&self) -> anyhow::Result<String> {
|
||||
let contents = std::fs::read_to_string(&self.config_path).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to read config file {} for Brave API key: {e}",
|
||||
self.config_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let config: crate::config::Config = toml::from_str(&contents).map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to parse config file {} for Brave API key: {e}",
|
||||
self.config_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let raw_key = config
|
||||
.web_search
|
||||
.brave_api_key
|
||||
.filter(|k| !k.is_empty())
|
||||
.ok_or_else(|| anyhow::anyhow!("Brave API key not configured"))?;
|
||||
|
||||
// Decrypt if necessary.
|
||||
if crate::security::SecretStore::is_encrypted(&raw_key) {
|
||||
let zeroclaw_dir = self.config_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets_encrypt);
|
||||
let plaintext = store.decrypt(&raw_key)?;
|
||||
if plaintext.is_empty() {
|
||||
anyhow::bail!("Brave API key not configured (decrypted value is empty)");
|
||||
}
|
||||
Ok(plaintext)
|
||||
} else {
|
||||
Ok(raw_key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,10 +186,7 @@ impl WebSearchTool {
|
||||
}
|
||||
|
||||
async fn search_brave(&self, query: &str) -> anyhow::Result<String> {
|
||||
let api_key = self
|
||||
.brave_api_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Brave API key not configured"))?;
|
||||
let api_key = self.resolve_brave_api_key()?;
|
||||
|
||||
let encoded_query = urlencoding::encode(query);
|
||||
let search_url = format!(
|
||||
@ -117,7 +201,7 @@ impl WebSearchTool {
|
||||
let response = client
|
||||
.get(&search_url)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Subscription-Token", api_key)
|
||||
.header("X-Subscription-Token", &api_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@ -328,4 +412,91 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("API key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_brave_api_key_uses_boot_key() {
|
||||
let tool = WebSearchTool::new(
|
||||
"brave".to_string(),
|
||||
Some("sk-plaintext-key".to_string()),
|
||||
5,
|
||||
15,
|
||||
);
|
||||
let key = tool.resolve_brave_api_key().unwrap();
|
||||
assert_eq!(key, "sk-plaintext-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_brave_api_key_reloads_from_config() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
"[web_search]\nbrave_api_key = \"fresh-key-from-disk\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// No boot key -- forces reload from config
|
||||
let tool =
|
||||
WebSearchTool::new_with_config("brave".to_string(), None, 5, 15, config_path, false);
|
||||
let key = tool.resolve_brave_api_key().unwrap();
|
||||
assert_eq!(key, "fresh-key-from-disk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_brave_api_key_decrypts_encrypted_key() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let store = crate::security::SecretStore::new(tmp.path(), true);
|
||||
let encrypted = store.encrypt("brave-secret-key").unwrap();
|
||||
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!("[web_search]\nbrave_api_key = \"{}\"\n", encrypted),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Boot key is the encrypted blob -- should trigger reload + decrypt
|
||||
let tool = WebSearchTool::new_with_config(
|
||||
"brave".to_string(),
|
||||
Some(encrypted),
|
||||
5,
|
||||
15,
|
||||
config_path,
|
||||
true,
|
||||
);
|
||||
let key = tool.resolve_brave_api_key().unwrap();
|
||||
assert_eq!(key, "brave-secret-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_brave_api_key_picks_up_runtime_update() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let config_path = tmp.path().join("config.toml");
|
||||
|
||||
// Start with no key in config
|
||||
std::fs::write(&config_path, "[web_search]\n").unwrap();
|
||||
|
||||
let tool = WebSearchTool::new_with_config(
|
||||
"brave".to_string(),
|
||||
None,
|
||||
5,
|
||||
15,
|
||||
config_path.clone(),
|
||||
false,
|
||||
);
|
||||
|
||||
// Key not configured yet -- should fail
|
||||
assert!(tool.resolve_brave_api_key().is_err());
|
||||
|
||||
// Simulate runtime config update (e.g. via web_search_config set)
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
"[web_search]\nbrave_api_key = \"runtime-updated-key\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Now should succeed with the updated key
|
||||
let key = tool.resolve_brave_api_key().unwrap();
|
||||
assert_eq!(key, "runtime-updated-key");
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,6 +304,11 @@ fn factory_resolves_opencode_provider() {
|
||||
assert_provider_ok("opencode", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_opencode_go_provider() {
|
||||
assert_provider_ok("opencode-go", Some("test-key"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_resolves_astrai_provider() {
|
||||
assert_provider_ok("astrai", Some("test-key"), None);
|
||||
|
||||
@ -151,6 +151,7 @@ async fn openai_codex_second_vision_support() -> Result<()> {
|
||||
zeroclaw_dir: None,
|
||||
secrets_encrypt: false,
|
||||
reasoning_enabled: None,
|
||||
provider_timeout_secs: None,
|
||||
};
|
||||
|
||||
let provider = zeroclaw::providers::create_provider_with_options("openai-codex", None, &opts)?;
|
||||
|
||||
208
web/package-lock.json
generated
208
web/package-lock.json
generated
@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "zeroclaw-web",
|
||||
"version": "0.1.0",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
@ -19,6 +20,7 @@
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"rollup": "^4.59.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
@ -806,9 +808,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -820,9 +822,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -834,9 +836,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -848,9 +850,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -862,9 +864,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -876,9 +878,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -890,9 +892,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -904,9 +906,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -918,9 +920,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -932,9 +934,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -946,9 +948,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -960,9 +962,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -974,9 +976,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -988,9 +990,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -1002,9 +1004,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -1016,9 +1018,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -1030,9 +1032,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@ -1044,9 +1046,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1058,9 +1060,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1072,9 +1074,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1086,9 +1088,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1100,9 +1102,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1114,9 +1116,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@ -1128,9 +1130,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1142,9 +1144,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2261,9 +2263,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@ -2277,31 +2279,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"rollup": "^4.59.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user