Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a9cec0b76 | |||
| 50e8d4f5f8 | |||
| fc2aac7c94 | |||
| 4caa3f7e6f | |||
| 3bc6ec3cf5 | |||
| f3fbd1b094 | |||
| 79e8252d7a | |||
| 924521c927 | |||
| 07ca270f03 | |||
| e08091a2e2 | |||
| 1f1123d071 | |||
| d5bc46238a | |||
| 843973762a | |||
| 5f8d7d7347 | |||
| 7b3bea8d01 | |||
| ac461dc704 | |||
| f04e56d9a1 | |||
| 1d6f482b04 | |||
| ba6d0a4df9 | |||
| 3cf873ab85 | |||
| 025724913d | |||
| 49dd4cd9da | |||
| 0664a5e854 | |||
| acd09fbd86 | |||
| 0f7d1fceeb | |||
| 01e13ac92d | |||
| a9a6113093 | |||
| 906951a587 | |||
| 220745e217 | |||
| 61de3d5648 | |||
| 675a5c9af0 | |||
| b099728c27 | |||
| 1ca2092ca0 | |||
| 5e3308eaaa | |||
| ec255ad788 | |||
| 7182f659ce | |||
| ae7681209d | |||
| ee3469e912 | |||
| fec81d8e75 | |||
| 9a073fae1a | |||
| f0db63e53c | |||
| df4dfeaf66 | |||
| e4ef25e913 | |||
| c3a3cfc9a6 | |||
| 013fca6ad2 | |||
| 23a0f25b44 | |||
| 2eaa8c45f4 | |||
| 85bf649432 | |||
| 3ea99a7619 | |||
| 14f58c77c1 | |||
| b833eb19f5 | |||
| 5db883b453 | |||
| 0ae515b6b8 | |||
| 2deb91455d | |||
| 595b81be41 | |||
| 4f9d817ddb | |||
| d13f5500e9 | |||
| 1ccfe643ba | |||
| d4d3e03e34 | |||
| aa0f11b0a2 | |||
| 806f8b4020 | |||
| 83803cef5b | |||
| dcb182cdd5 | |||
| 7c36a403b0 | |||
| 058dbc8786 | |||
| aff7a19494 | |||
| 0894429b54 | |||
| 6b03e885fc | |||
| 84470a2dd2 | |||
| 8a890be021 | |||
| 74a5ff78e7 | |||
| 93b16dece5 | |||
| c773170753 | |||
| f210b43977 | |||
| 50bc360bf4 | |||
| fc8ed583a0 | |||
| d593b6b1e4 | |||
| 426faa3923 | |||
| 85429b3657 | |||
| 8adf05f307 | |||
| a5f844d7cc | |||
| 7a9e815948 | |||
| 46378cf8b4 | |||
| c2133e6e62 | |||
| e9b3148e73 | |||
| 3d007f6b55 | |||
| f349de78ed | |||
| cd40051f4c | |||
| 6e4b1ede28 | |||
| cfba009833 | |||
| 45abd27e4a | |||
| 566e3cf35b | |||
| d642b0f3c8 | |||
| 8153992a11 | |||
| 98688c61ff | |||
| 9ba5ba5632 | |||
| 318ed8e9f1 | |||
| 5ddea82789 | |||
| a2b18bdb16 | |||
| 85271441b5 | |||
| 813ae17f48 | |||
| 794d87d95c | |||
| d6e5907b65 | |||
| 861dd3e2e9 | |||
| 8a61a283b2 | |||
| dcc0a629ec | |||
| a8c6363cde | |||
| d9ab017df0 | |||
| 249434edb2 | |||
| 62781a8d45 | |||
| 0adec305f9 | |||
| 75701195d7 | |||
| 82fe2e53fd | |||
| 327e2b4c47 | |||
| 5a5d9ae5f9 | |||
| d8f228bd15 | |||
| d5eeaed3d9 | |||
| 429094b049 | |||
| 80213b08ef | |||
| bf67124499 | |||
| cb250dfecf | |||
| fabd35c4ea | |||
| a695ca4b9c | |||
| 811fab3b87 | |||
| 1a5d91fe69 | |||
| 6eec1c81b9 | |||
| 602db8bca1 | |||
| 2539bcafe0 | |||
| 37d76f7c42 | |||
| 41b46f23e3 | |||
| 314e1d3ae8 | |||
| 82be05b1e9 | |||
| 1373659058 | |||
| c7f064e866 | |||
| 9c1d63e109 | |||
| 966edf1553 | |||
| a1af84d992 | |||
| 0ad1965081 | |||
| 70e8e7ebcd | |||
| 2bcb82c5b3 | |||
| e211b5c3e3 | |||
| 8691476577 | |||
| e34a804255 | |||
| 6120b3f705 | |||
| f175261e32 | |||
| fd9f66cad7 | |||
| d928ebc92e | |||
| 9fca9f478a | |||
| 7106632b51 | |||
| b834278754 | |||
| 186f6d9797 | |||
| 6cdc92a256 | |||
| 02599dcd3c | |||
| fe64d7ef7e | |||
| 996dbe95cf | |||
| 45f953be6d | |||
| 82f29bbcb1 | |||
| 93b5a0b824 | |||
| 08a67c4a2d | |||
| c86a0673ba | |||
| cabf99ba07 | |||
| 2d978a6b64 | |||
| 4dbc9266c1 | |||
| ea0b3c8c8c |
@@ -74,4 +74,4 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
name: Pub AUR Package
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Existing release tag (vX.Y.Z)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Generate PKGBUILD only (no push)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
secrets:
|
||||
AUR_SSH_KEY:
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Existing release tag (vX.Y.Z)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Generate PKGBUILD only (no push)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: aur-publish-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-aur:
|
||||
name: Update AUR Package
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate and compute metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::release_tag must be vX.Y.Z format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${RELEASE_TAG#v}"
|
||||
tarball_url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${RELEASE_TAG}.tar.gz"
|
||||
tarball_sha="$(curl -fsSL "$tarball_url" | sha256sum | awk '{print $1}')"
|
||||
|
||||
if [[ -z "$tarball_sha" ]]; then
|
||||
echo "::error::Could not compute SHA256 for source tarball."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "tarball_url=$tarball_url"
|
||||
echo "tarball_sha=$tarball_sha"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "### AUR Package Metadata"
|
||||
echo "- version: \`${version}\`"
|
||||
echo "- tarball_url: \`${tarball_url}\`"
|
||||
echo "- tarball_sha: \`${tarball_sha}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate PKGBUILD
|
||||
id: pkgbuild
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
TARBALL_SHA: ${{ steps.meta.outputs.tarball_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
pkgbuild_file="$(mktemp)"
|
||||
sed -e "s/^pkgver=.*/pkgver=${VERSION}/" \
|
||||
-e "s/^sha256sums=.*/sha256sums=('${TARBALL_SHA}')/" \
|
||||
dist/aur/PKGBUILD > "$pkgbuild_file"
|
||||
|
||||
echo "pkgbuild_file=$pkgbuild_file" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "### Generated PKGBUILD" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```bash' >> "$GITHUB_STEP_SUMMARY"
|
||||
cat "$pkgbuild_file" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate .SRCINFO
|
||||
id: srcinfo
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
TARBALL_SHA: ${{ steps.meta.outputs.tarball_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
srcinfo_file="$(mktemp)"
|
||||
sed -e "s/pkgver = .*/pkgver = ${VERSION}/" \
|
||||
-e "s/sha256sums = .*/sha256sums = ${TARBALL_SHA}/" \
|
||||
-e "s|zeroclaw-[0-9.]*.tar.gz|zeroclaw-${VERSION}.tar.gz|g" \
|
||||
-e "s|/v[0-9.]*\.tar\.gz|/v${VERSION}.tar.gz|g" \
|
||||
dist/aur/.SRCINFO > "$srcinfo_file"
|
||||
|
||||
echo "srcinfo_file=$srcinfo_file" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push to AUR
|
||||
if: inputs.dry_run == false
|
||||
shell: bash
|
||||
env:
|
||||
AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }}
|
||||
PKGBUILD_FILE: ${{ steps.pkgbuild.outputs.pkgbuild_file }}
|
||||
SRCINFO_FILE: ${{ steps.srcinfo.outputs.srcinfo_file }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${AUR_SSH_KEY}" ]]; then
|
||||
echo "::error::Secret AUR_SSH_KEY is required for non-dry-run."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
echo "$AUR_SSH_KEY" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
cat >> ~/.ssh/config <<SSH_CONFIG
|
||||
Host aur.archlinux.org
|
||||
IdentityFile ~/.ssh/aur
|
||||
User aur
|
||||
StrictHostKeyChecking accept-new
|
||||
SSH_CONFIG
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
git clone ssh://aur@aur.archlinux.org/zeroclaw.git "$tmp_dir/aur"
|
||||
|
||||
cp "$PKGBUILD_FILE" "$tmp_dir/aur/PKGBUILD"
|
||||
cp "$SRCINFO_FILE" "$tmp_dir/aur/.SRCINFO"
|
||||
|
||||
cd "$tmp_dir/aur"
|
||||
git config user.name "zeroclaw-bot"
|
||||
git config user.email "bot@zeroclaw.dev"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "zeroclaw ${VERSION}"
|
||||
git push origin HEAD
|
||||
|
||||
echo "AUR package updated to ${VERSION}"
|
||||
|
||||
- name: Summary
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "Dry run complete: PKGBUILD generated, no push performed."
|
||||
else
|
||||
echo "Publish complete: AUR package pushed."
|
||||
fi
|
||||
@@ -0,0 +1,206 @@
|
||||
name: Pub Homebrew Core
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Existing release tag to publish (vX.Y.Z)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Patch formula only (no push/PR)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: homebrew-core-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-homebrew-core:
|
||||
name: Publish Homebrew Core PR
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UPSTREAM_REPO: Homebrew/homebrew-core
|
||||
FORMULA_PATH: Formula/z/zeroclaw.rb
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
BOT_FORK_REPO: ${{ vars.HOMEBREW_CORE_BOT_FORK_REPO }}
|
||||
BOT_EMAIL: ${{ vars.HOMEBREW_CORE_BOT_EMAIL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate release tag and version alignment
|
||||
id: release_meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
semver_pattern='^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$'
|
||||
if [[ ! "$RELEASE_TAG" =~ $semver_pattern ]]; then
|
||||
echo "::error::release_tag must match semver-like format (vX.Y.Z[-suffix])."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git rev-parse "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
git fetch --tags origin
|
||||
fi
|
||||
|
||||
tag_version="${RELEASE_TAG#v}"
|
||||
cargo_version="$(git show "${RELEASE_TAG}:Cargo.toml" \
|
||||
| sed -n 's/^version = "\([^"]*\)"/\1/p' | head -n1)"
|
||||
if [[ -z "$cargo_version" ]]; then
|
||||
echo "::error::Unable to read Cargo.toml version from tag ${RELEASE_TAG}."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$cargo_version" != "$tag_version" ]]; then
|
||||
echo "::error::Tag ${RELEASE_TAG} does not match Cargo.toml version (${cargo_version})."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tarball_url="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${RELEASE_TAG}.tar.gz"
|
||||
tarball_sha="$(curl -fsSL "$tarball_url" | sha256sum | awk '{print $1}')"
|
||||
|
||||
{
|
||||
echo "tag_version=$tag_version"
|
||||
echo "tarball_url=$tarball_url"
|
||||
echo "tarball_sha=$tarball_sha"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "### Release Metadata"
|
||||
echo "- release_tag: \`${RELEASE_TAG}\`"
|
||||
echo "- cargo_version: \`${cargo_version}\`"
|
||||
echo "- tarball_sha256: \`${tarball_sha}\`"
|
||||
echo "- dry_run: ${DRY_RUN}"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Patch Homebrew formula
|
||||
id: patch_formula
|
||||
shell: bash
|
||||
env:
|
||||
HOMEBREW_CORE_BOT_TOKEN: ${{ secrets.HOMEBREW_UPSTREAM_PR_TOKEN || secrets.HOMEBREW_CORE_BOT_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_UPSTREAM_PR_TOKEN || secrets.HOMEBREW_CORE_BOT_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tmp_repo="$(mktemp -d)"
|
||||
echo "tmp_repo=$tmp_repo" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
git clone --depth=1 "https://github.com/${UPSTREAM_REPO}.git" "$tmp_repo/homebrew-core"
|
||||
else
|
||||
if [[ -z "${BOT_FORK_REPO}" ]]; then
|
||||
echo "::error::Repository variable HOMEBREW_CORE_BOT_FORK_REPO is required when dry_run=false."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${HOMEBREW_CORE_BOT_TOKEN}" ]]; then
|
||||
echo "::error::Repository secret HOMEBREW_CORE_BOT_TOKEN is required when dry_run=false."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$BOT_FORK_REPO" != */* ]]; then
|
||||
echo "::error::HOMEBREW_CORE_BOT_FORK_REPO must be in owner/repo format."
|
||||
exit 1
|
||||
fi
|
||||
if ! gh api "repos/${BOT_FORK_REPO}" >/dev/null 2>&1; then
|
||||
echo "::error::HOMEBREW_CORE_BOT_TOKEN cannot access ${BOT_FORK_REPO}."
|
||||
exit 1
|
||||
fi
|
||||
gh repo clone "${BOT_FORK_REPO}" "$tmp_repo/homebrew-core" -- --depth=1
|
||||
fi
|
||||
|
||||
repo_dir="$tmp_repo/homebrew-core"
|
||||
formula_file="$repo_dir/$FORMULA_PATH"
|
||||
if [[ ! -f "$formula_file" ]]; then
|
||||
echo "::error::Formula file not found: $FORMULA_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "false" ]]; then
|
||||
if git -C "$repo_dir" remote get-url upstream >/dev/null 2>&1; then
|
||||
git -C "$repo_dir" remote set-url upstream "https://github.com/${UPSTREAM_REPO}.git"
|
||||
else
|
||||
git -C "$repo_dir" remote add upstream "https://github.com/${UPSTREAM_REPO}.git"
|
||||
fi
|
||||
if git -C "$repo_dir" ls-remote --exit-code --heads upstream main >/dev/null 2>&1; then
|
||||
upstream_ref="main"
|
||||
else
|
||||
upstream_ref="master"
|
||||
fi
|
||||
git -C "$repo_dir" fetch --depth=1 upstream "$upstream_ref"
|
||||
branch_name="zeroclaw-${RELEASE_TAG}-${GITHUB_RUN_ID}"
|
||||
git -C "$repo_dir" checkout -B "$branch_name" "upstream/$upstream_ref"
|
||||
echo "branch_name=$branch_name" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
tarball_url="$(grep 'tarball_url=' "$GITHUB_OUTPUT" | head -1 | cut -d= -f2-)"
|
||||
tarball_sha="$(grep 'tarball_sha=' "$GITHUB_OUTPUT" | head -1 | cut -d= -f2-)"
|
||||
|
||||
perl -0pi -e "s|^ url \".*\"| url \"${tarball_url}\"|m" "$formula_file"
|
||||
perl -0pi -e "s|^ sha256 \".*\"| sha256 \"${tarball_sha}\"|m" "$formula_file"
|
||||
perl -0pi -e "s|^ license \".*\"| license \"Apache-2.0 OR MIT\"|m" "$formula_file"
|
||||
|
||||
git -C "$repo_dir" diff -- "$FORMULA_PATH" > "$tmp_repo/formula.diff"
|
||||
if [[ ! -s "$tmp_repo/formula.diff" ]]; then
|
||||
echo "::error::No formula changes generated. Nothing to publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "### Formula Diff"
|
||||
echo '```diff'
|
||||
cat "$tmp_repo/formula.diff"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Push branch and open Homebrew PR
|
||||
if: inputs.dry_run == false
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_UPSTREAM_PR_TOKEN || secrets.HOMEBREW_CORE_BOT_TOKEN }}
|
||||
TMP_REPO: ${{ steps.patch_formula.outputs.tmp_repo }}
|
||||
BRANCH_NAME: ${{ steps.patch_formula.outputs.branch_name }}
|
||||
TAG_VERSION: ${{ steps.release_meta.outputs.tag_version }}
|
||||
TARBALL_URL: ${{ steps.release_meta.outputs.tarball_url }}
|
||||
TARBALL_SHA: ${{ steps.release_meta.outputs.tarball_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="${TMP_REPO}/homebrew-core"
|
||||
fork_owner="${BOT_FORK_REPO%%/*}"
|
||||
bot_email="${BOT_EMAIL:-${fork_owner}@users.noreply.github.com}"
|
||||
|
||||
git -C "$repo_dir" config user.name "$fork_owner"
|
||||
git -C "$repo_dir" config user.email "$bot_email"
|
||||
git -C "$repo_dir" add "$FORMULA_PATH"
|
||||
git -C "$repo_dir" commit -m "zeroclaw ${TAG_VERSION}"
|
||||
gh auth setup-git
|
||||
git -C "$repo_dir" push --set-upstream origin "$BRANCH_NAME"
|
||||
|
||||
pr_body="Automated formula bump from ZeroClaw release workflow.
|
||||
|
||||
- Release tag: ${RELEASE_TAG}
|
||||
- Source tarball: ${TARBALL_URL}
|
||||
- Source sha256: ${TARBALL_SHA}"
|
||||
|
||||
gh pr create \
|
||||
--repo "$UPSTREAM_REPO" \
|
||||
--base main \
|
||||
--head "${fork_owner}:${BRANCH_NAME}" \
|
||||
--title "zeroclaw ${TAG_VERSION}" \
|
||||
--body "$pr_body"
|
||||
|
||||
- name: Summary
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo "Dry run complete: formula diff generated, no push/PR performed."
|
||||
else
|
||||
echo "Publish complete: branch pushed and PR opened from bot fork."
|
||||
fi
|
||||
@@ -0,0 +1,165 @@
|
||||
name: Pub Scoop Manifest
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Existing release tag (vX.Y.Z)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Generate manifest only (no push)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
secrets:
|
||||
SCOOP_BUCKET_TOKEN:
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Existing release tag (vX.Y.Z)"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Generate manifest only (no push)"
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: scoop-publish-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-scoop:
|
||||
name: Update Scoop Manifest
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
SCOOP_BUCKET_REPO: ${{ vars.SCOOP_BUCKET_REPO }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate and compute metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::error::release_tag must be vX.Y.Z format."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${RELEASE_TAG#v}"
|
||||
zip_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/zeroclaw-x86_64-pc-windows-msvc.zip"
|
||||
sums_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/SHA256SUMS"
|
||||
|
||||
sha256="$(curl -fsSL "$sums_url" | grep 'zeroclaw-x86_64-pc-windows-msvc.zip' | awk '{print $1}')"
|
||||
|
||||
if [[ -z "$sha256" ]]; then
|
||||
echo "::error::Could not find Windows binary hash in SHA256SUMS for ${RELEASE_TAG}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "zip_url=$zip_url"
|
||||
echo "sha256=$sha256"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "### Scoop Manifest Metadata"
|
||||
echo "- version: \`${version}\`"
|
||||
echo "- zip_url: \`${zip_url}\`"
|
||||
echo "- sha256: \`${sha256}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Generate manifest
|
||||
id: manifest
|
||||
shell: bash
|
||||
env:
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
ZIP_URL: ${{ steps.meta.outputs.zip_url }}
|
||||
SHA256: ${{ steps.meta.outputs.sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
manifest_file="$(mktemp)"
|
||||
cat > "$manifest_file" <<MANIFEST
|
||||
{
|
||||
"version": "${VERSION}",
|
||||
"description": "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.",
|
||||
"homepage": "https://github.com/zeroclaw-labs/zeroclaw",
|
||||
"license": "MIT|Apache-2.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "${ZIP_URL}",
|
||||
"hash": "${SHA256}",
|
||||
"bin": "zeroclaw.exe"
|
||||
}
|
||||
},
|
||||
"checkver": {
|
||||
"github": "https://github.com/zeroclaw-labs/zeroclaw"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v\$version/zeroclaw-x86_64-pc-windows-msvc.zip"
|
||||
}
|
||||
},
|
||||
"hash": {
|
||||
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v\$version/SHA256SUMS",
|
||||
"regex": "([a-f0-9]{64})\\\\s+zeroclaw-x86_64-pc-windows-msvc\\\\.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
MANIFEST
|
||||
|
||||
jq '.' "$manifest_file" > "${manifest_file}.formatted"
|
||||
mv "${manifest_file}.formatted" "$manifest_file"
|
||||
|
||||
echo "manifest_file=$manifest_file" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "### Generated Manifest" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```json' >> "$GITHUB_STEP_SUMMARY"
|
||||
cat "$manifest_file" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Push to Scoop bucket
|
||||
if: inputs.dry_run == false
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }}
|
||||
MANIFEST_FILE: ${{ steps.manifest.outputs.manifest_file }}
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${SCOOP_BUCKET_REPO}" ]]; then
|
||||
echo "::error::Repository variable SCOOP_BUCKET_REPO is required (e.g. zeroclaw-labs/scoop-zeroclaw)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
gh repo clone "${SCOOP_BUCKET_REPO}" "$tmp_dir/bucket" -- --depth=1
|
||||
|
||||
mkdir -p "$tmp_dir/bucket/bucket"
|
||||
cp "$MANIFEST_FILE" "$tmp_dir/bucket/bucket/zeroclaw.json"
|
||||
|
||||
cd "$tmp_dir/bucket"
|
||||
git config user.name "zeroclaw-bot"
|
||||
git config user.email "bot@zeroclaw.dev"
|
||||
git add bucket/zeroclaw.json
|
||||
git commit -m "zeroclaw ${VERSION}"
|
||||
gh auth setup-git
|
||||
git push origin HEAD
|
||||
|
||||
echo "Scoop manifest updated to ${VERSION}"
|
||||
@@ -103,9 +103,20 @@ jobs:
|
||||
run: rm -rf web/node_modules web/src web/package.json web/package-lock.json web/tsconfig*.json web/vite.config.ts web/index.html
|
||||
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --locked --allow-dirty --no-verify
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.detect-version-change.outputs.version }}
|
||||
run: |
|
||||
# Publish to crates.io; treat "already exists" as success
|
||||
# (manual publish or stable workflow may have already published)
|
||||
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
|
||||
echo "$OUTPUT"
|
||||
if echo "$OUTPUT" | grep -q 'already exists'; then
|
||||
echo "::notice::zeroclawlabs@${VERSION} already on crates.io — skipping"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
|
||||
- name: Verify published
|
||||
shell: bash
|
||||
|
||||
@@ -75,6 +75,16 @@ jobs:
|
||||
|
||||
- name: Publish to crates.io
|
||||
if: "!inputs.dry_run"
|
||||
run: cargo publish --locked --allow-dirty --no-verify
|
||||
shell: bash
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
# Publish to crates.io; treat "already exists" as success
|
||||
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
|
||||
echo "$OUTPUT"
|
||||
if echo "$OUTPUT" | grep -q 'already exists'; then
|
||||
echo "::notice::zeroclawlabs@${VERSION} already on crates.io — skipping"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
|
||||
@@ -5,8 +5,8 @@ on:
|
||||
branches: [master]
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: false
|
||||
group: release-beta
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -155,11 +155,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
# Use ubuntu-22.04 for Linux builds to link against glibc 2.35,
|
||||
# ensuring compatibility with Ubuntu 22.04+ (#3573).
|
||||
- os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact: zeroclaw
|
||||
ext: tar.gz
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-22.04
|
||||
target: aarch64-unknown-linux-gnu
|
||||
artifact: zeroclaw
|
||||
ext: tar.gz
|
||||
@@ -187,6 +189,8 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
if: runner.os != 'Windows'
|
||||
with:
|
||||
prefix-key: ${{ matrix.os }}-${{ matrix.target }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -209,7 +213,7 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -290,11 +294,45 @@ jobs:
|
||||
name: Push Docker Image
|
||||
needs: [version, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-x86_64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-aarch64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- name: Prepare Docker context with pre-built binaries
|
||||
run: |
|
||||
mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
|
||||
tar xzf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
|
||||
tar xzf artifacts/zeroclaw-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64
|
||||
|
||||
mkdir -p docker-ctx/zeroclaw-data/.zeroclaw docker-ctx/zeroclaw-data/workspace
|
||||
printf '%s\n' \
|
||||
'workspace_dir = "/zeroclaw-data/workspace"' \
|
||||
'config_path = "/zeroclaw-data/.zeroclaw/config.toml"' \
|
||||
'api_key = ""' \
|
||||
'default_provider = "openrouter"' \
|
||||
'default_model = "anthropic/claude-sonnet-4-20250514"' \
|
||||
'default_temperature = 0.7' \
|
||||
'' \
|
||||
'[gateway]' \
|
||||
'port = 42617' \
|
||||
'host = "[::]"' \
|
||||
'allow_public_bind = true' \
|
||||
> docker-ctx/zeroclaw-data/.zeroclaw/config.toml
|
||||
|
||||
cp Dockerfile.ci docker-ctx/Dockerfile
|
||||
cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian
|
||||
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
@@ -305,21 +343,24 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
context: docker-ctx
|
||||
push: true
|
||||
build-args: |
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ── Post-publish: only run after ALL artifacts are live ──────────────
|
||||
tweet:
|
||||
name: Tweet Release
|
||||
needs: [version, publish, docker, redeploy-website]
|
||||
uses: ./.github/workflows/tweet-release.yml
|
||||
with:
|
||||
release_tag: ${{ needs.version.outputs.tag }}
|
||||
release_url: https://github.com/zeroclaw-labs/zeroclaw/releases/tag/${{ needs.version.outputs.tag }}
|
||||
secrets: inherit
|
||||
- name: Build and push Debian compatibility image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: docker-ctx
|
||||
file: docker-ctx/Dockerfile.debian
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.version.outputs.tag }}-debian
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:beta-debian
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# Tweet removed — only stable releases should tweet (see tweet-release.yml).
|
||||
|
||||
@@ -156,11 +156,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
# Use ubuntu-22.04 for Linux builds to link against glibc 2.35,
|
||||
# ensuring compatibility with Ubuntu 22.04+ (#3573).
|
||||
- os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact: zeroclaw
|
||||
ext: tar.gz
|
||||
- os: ubuntu-latest
|
||||
- os: ubuntu-22.04
|
||||
target: aarch64-unknown-linux-gnu
|
||||
artifact: zeroclaw
|
||||
ext: tar.gz
|
||||
@@ -188,6 +190,8 @@ jobs:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2
|
||||
if: runner.os != 'Windows'
|
||||
with:
|
||||
prefix-key: ${{ matrix.os }}-${{ matrix.target }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -210,7 +214,7 @@ jobs:
|
||||
if [ -n "${{ matrix.linker_env || '' }}" ] && [ -n "${{ matrix.linker || '' }}" ]; then
|
||||
export "${{ matrix.linker_env }}=${{ matrix.linker }}"
|
||||
fi
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
cargo build --release --locked --features channel-matrix --target ${{ matrix.target }}
|
||||
|
||||
- name: Package (Unix)
|
||||
if: runner.os != 'Windows'
|
||||
@@ -303,13 +307,16 @@ jobs:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
# Skip if this version is already on crates.io (auto-sync may have published it)
|
||||
# Publish to crates.io; treat "already exists" as success
|
||||
# (auto-publish workflow may have already published this version)
|
||||
CRATE_NAME=$(sed -n 's/^name = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
|
||||
if curl -sfL "https://crates.io/api/v1/crates/${CRATE_NAME}/${VERSION}" | grep -q '"version"'; then
|
||||
echo "::notice::${CRATE_NAME}@${VERSION} already published on crates.io — skipping"
|
||||
else
|
||||
cargo publish --locked --allow-dirty --no-verify
|
||||
OUTPUT=$(cargo publish --locked --allow-dirty --no-verify 2>&1) && exit 0
|
||||
echo "$OUTPUT"
|
||||
if echo "$OUTPUT" | grep -q 'already exists'; then
|
||||
echo "::notice::${CRATE_NAME}@${VERSION} already on crates.io — skipping"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
|
||||
redeploy-website:
|
||||
name: Trigger Website Redeploy
|
||||
@@ -330,11 +337,45 @@ jobs:
|
||||
name: Push Docker Image
|
||||
needs: [validate, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-x86_64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: zeroclaw-aarch64-unknown-linux-gnu
|
||||
path: artifacts/
|
||||
|
||||
- name: Prepare Docker context with pre-built binaries
|
||||
run: |
|
||||
mkdir -p docker-ctx/bin/amd64 docker-ctx/bin/arm64
|
||||
tar xzf artifacts/zeroclaw-x86_64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/amd64
|
||||
tar xzf artifacts/zeroclaw-aarch64-unknown-linux-gnu.tar.gz -C docker-ctx/bin/arm64
|
||||
|
||||
mkdir -p docker-ctx/zeroclaw-data/.zeroclaw docker-ctx/zeroclaw-data/workspace
|
||||
printf '%s\n' \
|
||||
'workspace_dir = "/zeroclaw-data/workspace"' \
|
||||
'config_path = "/zeroclaw-data/.zeroclaw/config.toml"' \
|
||||
'api_key = ""' \
|
||||
'default_provider = "openrouter"' \
|
||||
'default_model = "anthropic/claude-sonnet-4-20250514"' \
|
||||
'default_temperature = 0.7' \
|
||||
'' \
|
||||
'[gateway]' \
|
||||
'port = 42617' \
|
||||
'host = "[::]"' \
|
||||
'allow_public_bind = true' \
|
||||
> docker-ctx/zeroclaw-data/.zeroclaw/config.toml
|
||||
|
||||
cp Dockerfile.ci docker-ctx/Dockerfile
|
||||
cp Dockerfile.debian.ci docker-ctx/Dockerfile.debian
|
||||
|
||||
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
with:
|
||||
@@ -345,19 +386,53 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
context: docker-ctx
|
||||
push: true
|
||||
build-args: |
|
||||
ZEROCLAW_CARGO_FEATURES=channel-matrix
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# ── Post-publish: only run after ALL artifacts are live ──────────────
|
||||
- name: Build and push Debian compatibility image
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: docker-ctx
|
||||
file: docker-ctx/Dockerfile.debian
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.validate.outputs.tag }}-debian
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:debian
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
# ── Post-publish: package manager auto-sync ─────────────────────────
|
||||
scoop:
|
||||
name: Update Scoop Manifest
|
||||
needs: [validate, publish]
|
||||
if: ${{ !cancelled() && needs.publish.result == 'success' }}
|
||||
uses: ./.github/workflows/pub-scoop.yml
|
||||
with:
|
||||
release_tag: ${{ needs.validate.outputs.tag }}
|
||||
dry_run: false
|
||||
secrets: inherit
|
||||
|
||||
aur:
|
||||
name: Update AUR Package
|
||||
needs: [validate, publish]
|
||||
if: ${{ !cancelled() && needs.publish.result == 'success' }}
|
||||
uses: ./.github/workflows/pub-aur.yml
|
||||
with:
|
||||
release_tag: ${{ needs.validate.outputs.tag }}
|
||||
dry_run: false
|
||||
secrets: inherit
|
||||
|
||||
# ── Post-publish: tweet after release + website are live ──────────────
|
||||
# Docker push can be slow; don't let it block the tweet.
|
||||
tweet:
|
||||
name: Tweet Release
|
||||
needs: [validate, publish, docker, crates-io, redeploy-website]
|
||||
needs: [validate, publish, redeploy-website]
|
||||
if: ${{ !cancelled() && needs.publish.result == 'success' }}
|
||||
uses: ./.github/workflows/tweet-release.yml
|
||||
with:
|
||||
release_tag: ${{ needs.validate.outputs.tag }}
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Release tag (e.g. v0.3.0 or v0.3.0-beta.42)"
|
||||
description: "Stable release tag (e.g. v0.3.0)"
|
||||
required: true
|
||||
type: string
|
||||
release_url:
|
||||
@@ -53,9 +53,18 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the PREVIOUS release tag (including betas) to check for new features
|
||||
# Stable releases (no -beta suffix) always tweet — they represent
|
||||
# the full release cycle, so skipping them loses visibility.
|
||||
if [[ ! "$RELEASE_TAG" =~ -beta\. ]]; then
|
||||
echo "Stable release ${RELEASE_TAG} — always tweet"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the previous STABLE release tag (exclude betas) to check for new features
|
||||
PREV_TAG=$(git tag --sort=-creatordate \
|
||||
| grep -v "^${RELEASE_TAG}$" \
|
||||
| grep -vE '\-beta\.' \
|
||||
| head -1 || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
@@ -63,15 +72,15 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Count new feat() commits since the previous release
|
||||
NEW_FEATS=$(git log "${PREV_TAG}..${RELEASE_TAG}" --pretty=format:"%s" --no-merges \
|
||||
| grep -ciE '^feat(\(|:)' || echo "0")
|
||||
# Count new feat() OR fix() commits since the previous release
|
||||
NEW_CHANGES=$(git log "${PREV_TAG}..${RELEASE_TAG}" --pretty=format:"%s" --no-merges \
|
||||
| grep -ciE '^(feat|fix)(\(|:)' || echo "0")
|
||||
|
||||
if [ "$NEW_FEATS" -eq 0 ]; then
|
||||
echo "No new features since ${PREV_TAG} — skipping tweet"
|
||||
if [ "$NEW_CHANGES" -eq 0 ]; then
|
||||
echo "No new features or fixes since ${PREV_TAG} — skipping tweet"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "${NEW_FEATS} new feature(s) since ${PREV_TAG} — tweeting"
|
||||
echo "${NEW_CHANGES} new change(s) since ${PREV_TAG} — tweeting"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
@@ -89,53 +98,37 @@ jobs:
|
||||
if [ -n "$MANUAL_TEXT" ]; then
|
||||
TWEET="$MANUAL_TEXT"
|
||||
else
|
||||
# For features: diff against the PREVIOUS release (including betas)
|
||||
# This prevents duplicate feature lists across consecutive betas
|
||||
PREV_RELEASE=$(git tag --sort=-creatordate \
|
||||
| grep -v "^${RELEASE_TAG}$" \
|
||||
| head -1 || echo "")
|
||||
|
||||
# For contributors: diff against the last STABLE release
|
||||
# This captures everyone across the full release cycle
|
||||
# Diff against the last STABLE release (exclude betas) to capture
|
||||
# ALL features accumulated across the full beta cycle
|
||||
PREV_STABLE=$(git tag --sort=-creatordate \
|
||||
| grep -v "^${RELEASE_TAG}$" \
|
||||
| grep -vE '\-beta\.' \
|
||||
| head -1 || echo "")
|
||||
|
||||
FEAT_RANGE="${PREV_RELEASE:+${PREV_RELEASE}..}${RELEASE_TAG}"
|
||||
CONTRIB_RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
|
||||
RANGE="${PREV_STABLE:+${PREV_STABLE}..}${RELEASE_TAG}"
|
||||
|
||||
# Extract NEW features only since the last release
|
||||
FEATURES=$(git log "$FEAT_RANGE" --pretty=format:"%s" --no-merges \
|
||||
# Extract ALL features since the last stable release
|
||||
FEATURES=$(git log "$RANGE" --pretty=format:"%s" --no-merges \
|
||||
| grep -iE '^feat(\(|:)' \
|
||||
| sed 's/^feat(\([^)]*\)): /\1: /' \
|
||||
| sed 's/^feat: //' \
|
||||
| sed 's/ (#[0-9]*)$//' \
|
||||
| sort -uf \
|
||||
| head -4 \
|
||||
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
|
||||
|
||||
if [ -z "$FEATURES" ]; then
|
||||
FEATURES="🚀 Incremental improvements and polish"
|
||||
fi
|
||||
|
||||
# Count ALL contributors across the full release cycle
|
||||
GIT_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%an" --no-merges | sort -uf || true)
|
||||
CO_AUTHORS=$(git log "$CONTRIB_RANGE" --pretty=format:"%b" --no-merges \
|
||||
| grep -ioE 'Co-Authored-By: *[^<]+' \
|
||||
| sed 's/Co-Authored-By: *//i' \
|
||||
| sed 's/ *$//' \
|
||||
| sort -uf || true)
|
||||
|
||||
TOTAL_COUNT=$(printf "%s\n%s" "$GIT_AUTHORS" "$CO_AUTHORS" \
|
||||
| sort -uf \
|
||||
| grep -v '^$' \
|
||||
| grep -viE '\[bot\]$|^dependabot|^github-actions|^copilot|^ZeroClaw Bot|^ZeroClaw Runner|^ZeroClaw Agent|^blacksmith' \
|
||||
| grep -c . || echo "0")
|
||||
FEAT_COUNT=$(echo "$FEATURES" | grep -c . || echo "0")
|
||||
|
||||
# Build tweet — new features, contributor count, hashtags
|
||||
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\n🙌 %s contributors\n\n%s\n\n#zeroclaw #rust #ai #opensource" \
|
||||
"$RELEASE_TAG" "$FEATURES" "$TOTAL_COUNT" "$RELEASE_URL")
|
||||
# Format top features with rocket emoji (limit to 6 for tweet space)
|
||||
FEAT_LIST=$(echo "$FEATURES" \
|
||||
| head -6 \
|
||||
| while IFS= read -r line; do echo "🚀 ${line}"; done || true)
|
||||
|
||||
if [ -z "$FEAT_LIST" ]; then
|
||||
FEAT_LIST="🚀 Incremental improvements and polish"
|
||||
fi
|
||||
|
||||
# Build tweet — feature-focused style
|
||||
TWEET=$(printf "🦀 ZeroClaw %s\n\n%s\n\nZero overhead. Zero compromise. 100%% Rust.\n\n#zeroclaw #rust #ai #opensource" \
|
||||
"$RELEASE_TAG" "$FEAT_LIST")
|
||||
fi
|
||||
|
||||
# X/Twitter counts any URL as 23 chars (t.co shortening).
|
||||
|
||||
+7
-2
@@ -1,6 +1,8 @@
|
||||
/target
|
||||
/target-*/
|
||||
firmware/*/target
|
||||
web/dist/
|
||||
web/dist/*
|
||||
!web/dist/.gitkeep
|
||||
*.db
|
||||
*.db-journal
|
||||
.DS_Store
|
||||
@@ -43,4 +45,7 @@ credentials.json
|
||||
lcov.info
|
||||
|
||||
# IDE's stuff
|
||||
.idea
|
||||
.idea
|
||||
|
||||
# Wrangler cache
|
||||
.wrangler/
|
||||
Generated
+2
-1
@@ -7945,7 +7945,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-imap",
|
||||
@@ -8005,6 +8005,7 @@ dependencies = [
|
||||
"serde-big-array",
|
||||
"serde_ignored",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"shellexpand",
|
||||
"tempfile",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ resolver = "2"
|
||||
|
||||
[package]
|
||||
name = "zeroclawlabs"
|
||||
version = "0.3.1"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
authors = ["theonlyhennygod"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -53,6 +53,7 @@ matrix-sdk = { version = "0.16", optional = true, default-features = false, feat
|
||||
serde = { version = "1.0", default-features = false, features = ["derive"] }
|
||||
serde_json = { version = "1.0", default-features = false, features = ["std"] }
|
||||
serde_ignored = "0.1"
|
||||
serde_yaml = "0.9"
|
||||
|
||||
# Config
|
||||
directories = "6.0"
|
||||
|
||||
+19
-3
@@ -1,9 +1,10 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────
|
||||
FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder
|
||||
FROM rust:1.94-slim@sha256:da9dab7a6b8dd428e71718402e97207bb3e54167d37b5708616050b1e8f60ed6 AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG ZEROCLAW_CARGO_FEATURES=""
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -24,7 +25,11 @@ RUN mkdir -p src benches crates/robot-kit/src \
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi
|
||||
RUN rm -rf src benches crates/robot-kit/src
|
||||
|
||||
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
|
||||
@@ -33,6 +38,7 @@ COPY benches/ benches/
|
||||
COPY crates/ crates/
|
||||
COPY firmware/ firmware/
|
||||
COPY web/ web/
|
||||
COPY *.rs .
|
||||
# Keep release builds resilient when frontend dist assets are not prebuilt in Git.
|
||||
RUN mkdir -p web/dist && \
|
||||
if [ ! -f web/dist/index.html ]; then \
|
||||
@@ -50,12 +56,22 @@ RUN mkdir -p web/dist && \
|
||||
' </body>' \
|
||||
'</html>' > web/dist/index.html; \
|
||||
fi
|
||||
RUN touch src/main.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked && \
|
||||
rm -rf target/release/.fingerprint/zeroclawlabs-* \
|
||||
target/release/deps/zeroclawlabs-* \
|
||||
target/release/incremental/zeroclawlabs-* && \
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi && \
|
||||
cp target/release/zeroclaw /app/zeroclaw && \
|
||||
strip /app/zeroclaw
|
||||
RUN size=$(stat -c%s /app/zeroclaw 2>/dev/null || stat -f%z /app/zeroclaw) && \
|
||||
if [ "$size" -lt 1000000 ]; then echo "ERROR: binary too small (${size} bytes), likely dummy build artifact" && exit 1; fi
|
||||
|
||||
# Prepare runtime directory structure and default config inline (no extra stage)
|
||||
RUN mkdir -p /zeroclaw-data/.zeroclaw /zeroclaw-data/workspace && \
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Dockerfile.ci — CI/release image using pre-built binaries.
|
||||
# Used by release workflows to skip the ~60 min Rust compilation.
|
||||
# The main Dockerfile is still used for local dev builds.
|
||||
|
||||
# ── Runtime (Distroless) ─────────────────────────────────────
|
||||
FROM gcr.io/distroless/cc-debian13:nonroot@sha256:84fcd3c223b144b0cb6edc5ecc75641819842a9679a3a58fd6294bec47532bf7
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
# Copy the pre-built binary for this platform (amd64 or arm64)
|
||||
COPY bin/${TARGETARCH}/zeroclaw /usr/local/bin/zeroclaw
|
||||
|
||||
# Runtime directory structure and default config
|
||||
COPY --chown=65534:65534 zeroclaw-data/ /zeroclaw-data/
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
+17
-4
@@ -15,10 +15,11 @@
|
||||
# Or with docker compose:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.debian.yml up
|
||||
|
||||
# ── Stage 1: Build (identical to main Dockerfile) ───────────
|
||||
FROM rust:1.93-slim@sha256:9663b80a1621253d30b146454f903de48f0af925c967be48c84745537cd35d8b AS builder
|
||||
# ── Stage 1: Build (match runtime glibc baseline) ───────────
|
||||
FROM rust:1.94-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ARG ZEROCLAW_CARGO_FEATURES=""
|
||||
|
||||
# Install build dependencies
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
@@ -33,12 +34,17 @@ COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
|
||||
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
|
||||
RUN mkdir -p src benches crates/robot-kit/src \
|
||||
&& echo "fn main() {}" > src/main.rs \
|
||||
&& echo "" > src/lib.rs \
|
||||
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
|
||||
&& echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi
|
||||
RUN rm -rf src benches crates/robot-kit/src
|
||||
|
||||
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
|
||||
@@ -64,12 +70,19 @@ RUN mkdir -p web/dist && \
|
||||
' </body>' \
|
||||
'</html>' > web/dist/index.html; \
|
||||
fi
|
||||
RUN touch src/main.rs
|
||||
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
|
||||
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
|
||||
cargo build --release --locked && \
|
||||
if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
|
||||
cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
|
||||
else \
|
||||
cargo build --release --locked; \
|
||||
fi && \
|
||||
cp target/release/zeroclaw /app/zeroclaw && \
|
||||
strip /app/zeroclaw
|
||||
RUN size=$(stat -c%s /app/zeroclaw 2>/dev/null || stat -f%z /app/zeroclaw) && \
|
||||
if [ "$size" -lt 1000000 ]; then echo "ERROR: binary too small (${size} bytes), likely dummy build artifact" && exit 1; fi
|
||||
|
||||
# Prepare runtime directory structure and default config inline (no extra stage)
|
||||
RUN mkdir -p /zeroclaw-data/.zeroclaw /zeroclaw-data/workspace && \
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Dockerfile.debian.ci — CI/release Debian image using pre-built binaries.
|
||||
# Mirrors Dockerfile.ci but uses debian:bookworm-slim with shell tools
|
||||
# so the agent can use shell-based tools (pwd, ls, git, curl, etc.).
|
||||
# Used by release workflows to skip ~60 min QEMU cross-compilation.
|
||||
|
||||
# ── Runtime (Debian with shell) ────────────────────────────────
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
# Install essential tools for agent shell operations
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy the pre-built binary for this platform (amd64 or arm64)
|
||||
COPY bin/${TARGETARCH}/zeroclaw /usr/local/bin/zeroclaw
|
||||
|
||||
# Runtime directory structure and default config
|
||||
COPY --chown=65534:65534 zeroclaw-data/ /zeroclaw-data/
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace
|
||||
ENV HOME=/zeroclaw-data
|
||||
ENV ZEROCLAW_GATEWAY_PORT=42617
|
||||
|
||||
WORKDIR /zeroclaw-data
|
||||
USER 65534:65534
|
||||
EXPOSE 42617
|
||||
ENTRYPOINT ["zeroclaw"]
|
||||
CMD ["gateway"]
|
||||
@@ -1,22 +1,30 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
|
||||
fn main() {
|
||||
let dist_dir = Path::new("web/dist");
|
||||
let web_dir = Path::new("web");
|
||||
|
||||
// Tell Cargo to re-run this script when web source files change.
|
||||
// Tell Cargo to re-run this script when web sources or bundled assets change.
|
||||
println!("cargo:rerun-if-changed=web/src");
|
||||
println!("cargo:rerun-if-changed=web/public");
|
||||
println!("cargo:rerun-if-changed=web/index.html");
|
||||
println!("cargo:rerun-if-changed=web/package.json");
|
||||
println!("cargo:rerun-if-changed=web/package-lock.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.app.json");
|
||||
println!("cargo:rerun-if-changed=web/tsconfig.node.json");
|
||||
println!("cargo:rerun-if-changed=web/vite.config.ts");
|
||||
println!("cargo:rerun-if-changed=web/dist");
|
||||
|
||||
// Attempt to build the web frontend if npm is available and web/dist is
|
||||
// missing or stale. The build is best-effort: when Node.js is not
|
||||
// installed (e.g. CI containers, cross-compilation, minimal dev setups)
|
||||
// we fall back to the existing stub/empty dist directory so the Rust
|
||||
// build still succeeds.
|
||||
let needs_build = !dist_dir.join("index.html").exists();
|
||||
let needs_build = web_build_required(web_dir, dist_dir);
|
||||
|
||||
if needs_build && web_dir.join("package.json").exists() {
|
||||
if let Ok(npm) = which_npm() {
|
||||
@@ -77,6 +85,49 @@ fn main() {
|
||||
ensure_dist_dir(dist_dir);
|
||||
}
|
||||
|
||||
fn web_build_required(web_dir: &Path, dist_dir: &Path) -> bool {
|
||||
let Some(dist_mtime) = latest_modified(dist_dir) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
[
|
||||
web_dir.join("src"),
|
||||
web_dir.join("public"),
|
||||
web_dir.join("index.html"),
|
||||
web_dir.join("package.json"),
|
||||
web_dir.join("package-lock.json"),
|
||||
web_dir.join("tsconfig.json"),
|
||||
web_dir.join("tsconfig.app.json"),
|
||||
web_dir.join("tsconfig.node.json"),
|
||||
web_dir.join("vite.config.ts"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|path| latest_modified(&path))
|
||||
.any(|mtime| mtime > dist_mtime)
|
||||
}
|
||||
|
||||
fn latest_modified(path: &Path) -> Option<SystemTime> {
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
if metadata.is_file() {
|
||||
return metadata.modified().ok();
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut latest = metadata.modified().ok();
|
||||
let entries = fs::read_dir(path).ok()?;
|
||||
for entry in entries.flatten() {
|
||||
if let Some(child_mtime) = latest_modified(&entry.path()) {
|
||||
latest = Some(match latest {
|
||||
Some(current) if current >= child_mtime => current,
|
||||
_ => child_mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
latest
|
||||
}
|
||||
|
||||
/// Ensure the dist directory exists so `rust-embed` does not fail at compile
|
||||
/// time even when the web frontend is not built.
|
||||
fn ensure_dist_dir(dist_dir: &Path) {
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env bash
|
||||
# Termux release validation script
|
||||
# Validates the aarch64-linux-android release artifact for Termux compatibility.
|
||||
#
|
||||
# Usage:
|
||||
# ./dev/test-termux-release.sh [version]
|
||||
#
|
||||
# Examples:
|
||||
# ./dev/test-termux-release.sh 0.3.1
|
||||
# ./dev/test-termux-release.sh # auto-detects from Cargo.toml
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BLUE='\033[0;34m'
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
RESET='\033[0m'
|
||||
|
||||
pass() { echo -e " ${GREEN}✓${RESET} $*"; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $*"; FAILURES=$((FAILURES + 1)); }
|
||||
info() { echo -e "${BLUE}→${RESET} ${BOLD}$*${RESET}"; }
|
||||
warn() { echo -e "${YELLOW}!${RESET} $*"; }
|
||||
|
||||
FAILURES=0
|
||||
TARGET="aarch64-linux-android"
|
||||
VERSION="${1:-}"
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
if [[ -f Cargo.toml ]]; then
|
||||
VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo " e.g. $0 0.3.1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TAG="v${VERSION}"
|
||||
ASSET_NAME="zeroclaw-${TARGET}.tar.gz"
|
||||
ASSET_URL="https://github.com/zeroclaw-labs/zeroclaw/releases/download/${TAG}/${ASSET_NAME}"
|
||||
TEMP_DIR="$(mktemp -d -t zeroclaw-termux-test-XXXXXX)"
|
||||
|
||||
cleanup() { rm -rf "$TEMP_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
echo
|
||||
echo -e "${BOLD}Termux Release Validation — ${TAG}${RESET}"
|
||||
echo -e "${DIM}Target: ${TARGET}${RESET}"
|
||||
echo
|
||||
|
||||
# --- Test 1: Release tag exists ---
|
||||
info "Checking release tag ${TAG}"
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
pass "Release ${TAG} exists"
|
||||
else
|
||||
fail "Release ${TAG} not found"
|
||||
echo -e "${RED}Release has not been published yet. Wait for the release workflow to complete.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Test 2: Android asset is listed ---
|
||||
info "Checking for ${ASSET_NAME} in release assets"
|
||||
ASSETS=$(gh release view "$TAG" --json assets -q '.assets[].name')
|
||||
if echo "$ASSETS" | grep -q "$ASSET_NAME"; then
|
||||
pass "Asset ${ASSET_NAME} found in release"
|
||||
else
|
||||
fail "Asset ${ASSET_NAME} not found in release"
|
||||
echo "Available assets:"
|
||||
echo "$ASSETS" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Test 3: Download the asset ---
|
||||
info "Downloading ${ASSET_NAME}"
|
||||
if curl -fsSL "$ASSET_URL" -o "$TEMP_DIR/$ASSET_NAME"; then
|
||||
FILESIZE=$(wc -c < "$TEMP_DIR/$ASSET_NAME" | tr -d ' ')
|
||||
pass "Downloaded successfully (${FILESIZE} bytes)"
|
||||
else
|
||||
fail "Download failed from ${ASSET_URL}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Test 4: Archive integrity ---
|
||||
info "Verifying archive integrity"
|
||||
if tar -tzf "$TEMP_DIR/$ASSET_NAME" >/dev/null 2>&1; then
|
||||
pass "Archive is a valid gzip tar"
|
||||
else
|
||||
fail "Archive is corrupted or not a valid tar.gz"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Test 5: Contains zeroclaw binary ---
|
||||
info "Checking archive contents"
|
||||
CONTENTS=$(tar -tzf "$TEMP_DIR/$ASSET_NAME")
|
||||
if echo "$CONTENTS" | grep -q "^zeroclaw$"; then
|
||||
pass "Archive contains 'zeroclaw' binary"
|
||||
else
|
||||
fail "Archive does not contain 'zeroclaw' binary"
|
||||
echo "Contents:"
|
||||
echo "$CONTENTS" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
# --- Test 6: Extract and inspect binary ---
|
||||
info "Extracting and inspecting binary"
|
||||
tar -xzf "$TEMP_DIR/$ASSET_NAME" -C "$TEMP_DIR"
|
||||
BINARY="$TEMP_DIR/zeroclaw"
|
||||
|
||||
if [[ -f "$BINARY" ]]; then
|
||||
pass "Binary extracted"
|
||||
else
|
||||
fail "Binary not found after extraction"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Test 7: ELF format and architecture ---
|
||||
info "Checking binary format"
|
||||
FILE_INFO=$(file "$BINARY")
|
||||
if echo "$FILE_INFO" | grep -q "ELF"; then
|
||||
pass "Binary is ELF format"
|
||||
else
|
||||
fail "Binary is not ELF format: $FILE_INFO"
|
||||
fi
|
||||
|
||||
if echo "$FILE_INFO" | grep -qi "aarch64\|ARM aarch64"; then
|
||||
pass "Binary targets aarch64 architecture"
|
||||
else
|
||||
fail "Binary does not target aarch64: $FILE_INFO"
|
||||
fi
|
||||
|
||||
if echo "$FILE_INFO" | grep -qi "android\|bionic"; then
|
||||
pass "Binary is linked for Android/Bionic"
|
||||
else
|
||||
# Android binaries may not always show "android" in file output,
|
||||
# check with readelf if available
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
INTERP=$(readelf -l "$BINARY" 2>/dev/null | grep -o '/[^ ]*linker[^ ]*' || true)
|
||||
if echo "$INTERP" | grep -qi "android\|bionic"; then
|
||||
pass "Binary uses Android linker: $INTERP"
|
||||
else
|
||||
warn "Could not confirm Android linkage (interpreter: ${INTERP:-unknown})"
|
||||
warn "file output: $FILE_INFO"
|
||||
fi
|
||||
else
|
||||
warn "Could not confirm Android linkage (readelf not available)"
|
||||
warn "file output: $FILE_INFO"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Test 8: Binary is stripped ---
|
||||
info "Checking binary optimization"
|
||||
if echo "$FILE_INFO" | grep -q "stripped"; then
|
||||
pass "Binary is stripped (release optimized)"
|
||||
else
|
||||
warn "Binary may not be stripped"
|
||||
fi
|
||||
|
||||
# --- Test 9: Binary is not dynamically linked to glibc ---
|
||||
info "Checking for glibc dependencies"
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
NEEDED=$(readelf -d "$BINARY" 2>/dev/null | grep NEEDED || true)
|
||||
if echo "$NEEDED" | grep -qi "libc\.so\.\|libpthread\|libdl"; then
|
||||
# Check if it's glibc or bionic
|
||||
if echo "$NEEDED" | grep -qi "libc\.so\.6"; then
|
||||
fail "Binary links against glibc (libc.so.6) — will not work on Termux"
|
||||
else
|
||||
pass "Binary links against libc (likely Bionic)"
|
||||
fi
|
||||
else
|
||||
pass "No glibc dependencies detected"
|
||||
fi
|
||||
else
|
||||
warn "readelf not available — skipping dynamic library check"
|
||||
fi
|
||||
|
||||
# --- Test 10: SHA256 checksum verification ---
|
||||
info "Verifying SHA256 checksum"
|
||||
CHECKSUMS_URL="https://github.com/zeroclaw-labs/zeroclaw/releases/download/${TAG}/SHA256SUMS"
|
||||
if curl -fsSL "$CHECKSUMS_URL" -o "$TEMP_DIR/SHA256SUMS" 2>/dev/null; then
|
||||
EXPECTED=$(grep "$ASSET_NAME" "$TEMP_DIR/SHA256SUMS" | awk '{print $1}')
|
||||
if [[ -n "$EXPECTED" ]]; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
ACTUAL=$(sha256sum "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
ACTUAL=$(shasum -a 256 "$TEMP_DIR/$ASSET_NAME" | awk '{print $1}')
|
||||
else
|
||||
warn "No sha256sum or shasum available"
|
||||
ACTUAL=""
|
||||
fi
|
||||
|
||||
if [[ -n "$ACTUAL" && "$ACTUAL" == "$EXPECTED" ]]; then
|
||||
pass "SHA256 checksum matches"
|
||||
elif [[ -n "$ACTUAL" ]]; then
|
||||
fail "SHA256 mismatch: expected=$EXPECTED actual=$ACTUAL"
|
||||
fi
|
||||
else
|
||||
warn "No checksum entry for ${ASSET_NAME} in SHA256SUMS"
|
||||
fi
|
||||
else
|
||||
warn "Could not download SHA256SUMS"
|
||||
fi
|
||||
|
||||
# --- Test 11: install.sh Termux detection ---
|
||||
info "Validating install.sh Termux detection"
|
||||
INSTALL_SH="install.sh"
|
||||
if [[ ! -f "$INSTALL_SH" ]]; then
|
||||
INSTALL_SH="$(dirname "$0")/../install.sh"
|
||||
fi
|
||||
|
||||
if [[ -f "$INSTALL_SH" ]]; then
|
||||
if grep -q 'TERMUX_VERSION' "$INSTALL_SH"; then
|
||||
pass "install.sh checks TERMUX_VERSION"
|
||||
else
|
||||
fail "install.sh does not check TERMUX_VERSION"
|
||||
fi
|
||||
|
||||
if grep -q 'aarch64-linux-android' "$INSTALL_SH"; then
|
||||
pass "install.sh maps to aarch64-linux-android target"
|
||||
else
|
||||
fail "install.sh does not map to aarch64-linux-android"
|
||||
fi
|
||||
|
||||
# Simulate Termux detection (mock uname as Linux since we may run on macOS)
|
||||
detect_result=$(
|
||||
bash -c '
|
||||
TERMUX_VERSION="0.118"
|
||||
os="Linux"
|
||||
arch="aarch64"
|
||||
case "$os:$arch" in
|
||||
Linux:aarch64|Linux:arm64)
|
||||
if [[ -n "${TERMUX_VERSION:-}" || -d "/data/data/com.termux" ]]; then
|
||||
echo "aarch64-linux-android"
|
||||
else
|
||||
echo "aarch64-unknown-linux-gnu"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
'
|
||||
)
|
||||
if [[ "$detect_result" == "aarch64-linux-android" ]]; then
|
||||
pass "Termux detection returns correct target (simulated)"
|
||||
else
|
||||
fail "Termux detection returned: $detect_result (expected aarch64-linux-android)"
|
||||
fi
|
||||
else
|
||||
warn "install.sh not found — skipping detection tests"
|
||||
fi
|
||||
|
||||
# --- Summary ---
|
||||
echo
|
||||
if [[ "$FAILURES" -eq 0 ]]; then
|
||||
echo -e "${GREEN}${BOLD}All tests passed!${RESET}"
|
||||
echo -e "${DIM}The Termux release artifact for ${TAG} is valid.${RESET}"
|
||||
else
|
||||
echo -e "${RED}${BOLD}${FAILURES} test(s) failed.${RESET}"
|
||||
exit 1
|
||||
fi
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
pkgbase = zeroclaw
|
||||
pkgdesc = Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.
|
||||
pkgver = 0.4.3
|
||||
pkgrel = 1
|
||||
url = https://github.com/zeroclaw-labs/zeroclaw
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
license = Apache-2.0
|
||||
makedepends = cargo
|
||||
makedepends = git
|
||||
depends = gcc-libs
|
||||
depends = openssl
|
||||
source = zeroclaw-0.4.3.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v0.4.3.tar.gz
|
||||
sha256sums = SKIP
|
||||
|
||||
pkgname = zeroclaw
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
# Maintainer: zeroclaw-labs <bot@zeroclaw.dev>
|
||||
pkgname=zeroclaw
|
||||
pkgver=0.4.3
|
||||
pkgrel=1
|
||||
pkgdesc="Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant."
|
||||
arch=('x86_64')
|
||||
url="https://github.com/zeroclaw-labs/zeroclaw"
|
||||
license=('MIT' 'Apache-2.0')
|
||||
depends=('gcc-libs' 'openssl')
|
||||
makedepends=('cargo' 'git')
|
||||
source=("${pkgname}-${pkgver}.tar.gz::https://github.com/zeroclaw-labs/zeroclaw/archive/refs/tags/v${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
prepare() {
|
||||
cd "${pkgname}-${pkgver}"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')"
|
||||
}
|
||||
|
||||
build() {
|
||||
cd "${pkgname}-${pkgver}"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
cargo build --frozen --release --profile dist
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${pkgname}-${pkgver}"
|
||||
install -Dm0755 -t "${pkgdir}/usr/bin/" "target/dist/zeroclaw"
|
||||
install -Dm0644 LICENSE-MIT "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE-APACHE"
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"version": "0.4.3",
|
||||
"description": "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.",
|
||||
"homepage": "https://github.com/zeroclaw-labs/zeroclaw",
|
||||
"license": "MIT|Apache-2.0",
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v0.4.3/zeroclaw-x86_64-pc-windows-msvc.zip",
|
||||
"hash": "",
|
||||
"bin": "zeroclaw.exe"
|
||||
}
|
||||
},
|
||||
"checkver": {
|
||||
"github": "https://github.com/zeroclaw-labs/zeroclaw"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v$version/zeroclaw-x86_64-pc-windows-msvc.zip"
|
||||
}
|
||||
},
|
||||
"hash": {
|
||||
"url": "https://github.com/zeroclaw-labs/zeroclaw/releases/download/v$version/SHA256SUMS",
|
||||
"regex": "([a-f0-9]{64})\\s+zeroclaw-x86_64-pc-windows-msvc\\.zip"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
services:
|
||||
zeroclaw:
|
||||
image: ghcr.io/zeroclaw-labs/zeroclaw:latest
|
||||
# For ARM64 environments where the distroless image exits immediately,
|
||||
# switch to the Debian compatibility image instead:
|
||||
# image: ghcr.io/zeroclaw-labs/zeroclaw:debian
|
||||
# Or build locally (distroless, no shell):
|
||||
# build: .
|
||||
# Or build the Debian variant (includes bash, git, curl):
|
||||
|
||||
@@ -37,6 +37,12 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||
- `.github/workflows/pub-homebrew-core.yml` (`Pub Homebrew Core`)
|
||||
- Purpose: manual, bot-owned Homebrew core formula bump PR flow for tagged releases
|
||||
- Guardrail: release tag must match `Cargo.toml` version
|
||||
- `.github/workflows/pub-scoop.yml` (`Pub Scoop Manifest`)
|
||||
- Purpose: Scoop bucket manifest update for Windows; auto-called by stable release, also manual dispatch
|
||||
- Guardrail: release tag must be `vX.Y.Z` format; Windows binary hash extracted from `SHA256SUMS`
|
||||
- `.github/workflows/pub-aur.yml` (`Pub AUR Package`)
|
||||
- Purpose: AUR PKGBUILD push for Arch Linux; auto-called by stable release, also manual dispatch
|
||||
- Guardrail: release tag must be `vX.Y.Z` format; source tarball SHA256 computed at publish time
|
||||
- `.github/workflows/pr-label-policy-check.yml` (`Label Policy Sanity`)
|
||||
- Purpose: validate shared contributor-tier policy in `.github/label-policy.json` and ensure label workflows consume that policy
|
||||
- `.github/workflows/test-rust-build.yml` (`Rust Reusable Job`)
|
||||
@@ -75,6 +81,8 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||
- `Docker`: tag push (`v*`) for publish, matching PRs to `master` for smoke build, manual dispatch for smoke only
|
||||
- `Release`: tag push (`v*`), weekly schedule (verification-only), manual dispatch (verification or publish)
|
||||
- `Pub Homebrew Core`: manual dispatch only
|
||||
- `Pub Scoop Manifest`: auto-called by stable release, also manual dispatch
|
||||
- `Pub AUR Package`: auto-called by stable release, also manual dispatch
|
||||
- `Security Audit`: push to `master`, PRs to `master`, weekly schedule
|
||||
- `Sec Vorpal Reviewdog`: manual dispatch only
|
||||
- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change
|
||||
@@ -92,12 +100,14 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
||||
2. Docker failures on PRs: inspect `.github/workflows/pub-docker-img.yml` `pr-smoke` job.
|
||||
3. Release failures (tag/manual/scheduled): inspect `.github/workflows/pub-release.yml` and the `prepare` job outputs.
|
||||
4. Homebrew formula publish failures: inspect `.github/workflows/pub-homebrew-core.yml` summary output and bot token/fork variables.
|
||||
5. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
|
||||
6. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
|
||||
7. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
|
||||
8. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
|
||||
9. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
|
||||
10. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
|
||||
5. Scoop manifest publish failures: inspect `.github/workflows/pub-scoop.yml` summary output and `SCOOP_BUCKET_REPO`/`SCOOP_BUCKET_TOKEN` settings.
|
||||
6. AUR package publish failures: inspect `.github/workflows/pub-aur.yml` summary output and `AUR_SSH_KEY` secret.
|
||||
7. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
|
||||
8. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
|
||||
9. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
|
||||
10. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
|
||||
11. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
|
||||
12. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ Release automation lives in:
|
||||
|
||||
- `.github/workflows/pub-release.yml`
|
||||
- `.github/workflows/pub-homebrew-core.yml` (manual Homebrew formula PR, bot-owned)
|
||||
- `.github/workflows/pub-scoop.yml` (manual Scoop bucket manifest update)
|
||||
- `.github/workflows/pub-aur.yml` (manual AUR PKGBUILD push)
|
||||
|
||||
Modes:
|
||||
|
||||
@@ -115,6 +117,41 @@ Workflow guardrails:
|
||||
- formula license is normalized to `Apache-2.0 OR MIT`
|
||||
- PR is opened from the bot fork into `Homebrew/homebrew-core:master`
|
||||
|
||||
### 7) Publish Scoop manifest (Windows)
|
||||
|
||||
Run `Pub Scoop Manifest` manually:
|
||||
|
||||
- `release_tag`: `vX.Y.Z`
|
||||
- `dry_run`: `true` first, then `false`
|
||||
|
||||
Required repository settings for non-dry-run:
|
||||
|
||||
- secret: `SCOOP_BUCKET_TOKEN` (PAT with push access to the bucket repo)
|
||||
- variable: `SCOOP_BUCKET_REPO` (for example `zeroclaw-labs/scoop-zeroclaw`)
|
||||
|
||||
Workflow guardrails:
|
||||
|
||||
- release tag must be `vX.Y.Z` format
|
||||
- Windows binary SHA256 extracted from `SHA256SUMS` release asset
|
||||
- manifest pushed to `bucket/zeroclaw.json` in the Scoop bucket repo
|
||||
|
||||
### 8) Publish AUR package (Arch Linux)
|
||||
|
||||
Run `Pub AUR Package` manually:
|
||||
|
||||
- `release_tag`: `vX.Y.Z`
|
||||
- `dry_run`: `true` first, then `false`
|
||||
|
||||
Required repository settings for non-dry-run:
|
||||
|
||||
- secret: `AUR_SSH_KEY` (SSH private key registered with AUR)
|
||||
|
||||
Workflow guardrails:
|
||||
|
||||
- release tag must be `vX.Y.Z` format
|
||||
- source tarball SHA256 computed from the tagged release
|
||||
- PKGBUILD and .SRCINFO pushed to AUR `zeroclaw` package
|
||||
|
||||
## Emergency / Recovery Path
|
||||
|
||||
If tag-push release fails after artifacts are validated:
|
||||
|
||||
@@ -31,7 +31,7 @@ Build with `--features hardware` to include Uno Q support.
|
||||
|
||||
### 1.1 Configure Uno Q via App Lab
|
||||
|
||||
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage on Linux).
|
||||
1. Download [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (tar.gz on Linux).
|
||||
2. Connect Uno Q via USB, power it on.
|
||||
3. Open App Lab, connect to the board.
|
||||
4. Follow the setup wizard:
|
||||
|
||||
+3
-4
@@ -1,15 +1,14 @@
|
||||
# ZeroClaw i18n Docs Index
|
||||
|
||||
Canonical localized documentation trees live here.
|
||||
Localized documentation trees live here and under `docs/`.
|
||||
|
||||
## Locales
|
||||
|
||||
- Vietnamese: [vi/README.md](vi/README.md)
|
||||
- Vietnamese (canonical): [`docs/vi/`](../vi/)
|
||||
- Chinese (Simplified): [`docs/i18n/zh-CN/`](zh-CN/)
|
||||
|
||||
## Structure
|
||||
|
||||
- Docs structure map (language/part/function): [../maintainers/structure-README.md](../maintainers/structure-README.md)
|
||||
- Canonical Vietnamese tree: `docs/i18n/vi/`
|
||||
- Compatibility Vietnamese paths: `docs/vi/` and `docs/*.vi.md`
|
||||
|
||||
See overall coverage and conventions in [../maintainers/i18n-coverage.md](../maintainers/i18n-coverage.md).
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
# Οδηγός Ρυθμίσεων ZeroClaw (config.toml)
|
||||
|
||||
Αυτός ο οδηγός εξηγεί τις πιο σημαντικές ρυθμίσεις που μπορείτε να κάνετε στο αρχείο `config.toml`.
|
||||
|
||||
Τελευταίος έλεγχος: **19 Φεβρουαρίου 2026**.
|
||||
|
||||
## Πού βρίσκεται το αρχείο ρυθμίσεων;
|
||||
|
||||
Το ZeroClaw ψάχνει για τις ρυθμίσεις με την εξής σειρά:
|
||||
1. Στη διαδρομή που ορίζει η μεταβλητή `ZEROCLAW_WORKSPACE`.
|
||||
2. Στο αρχείο `~/.zeroclaw/config.toml` (αυτή είναι η συνηθισμένη θέση).
|
||||
|
||||
## Βασικές Ρυθμίσεις (Core)
|
||||
|
||||
| Ρύθμιση | Τι ορίζει |
|
||||
|---|---|
|
||||
| `default_provider` | Ποιον πάροχο AI χρησιμοποιείτε (π.χ. `openai`, `ollama`). |
|
||||
| `default_model` | Ποιο συγκεκριμένο μοντέλο AI χρησιμοποιείτε (π.χ. `gpt-4o`). |
|
||||
| `default_temperature` | Πόσο "δημιουργική" θα είναι η AI (τιμή από 0 έως 2). |
|
||||
|
||||
## 1. Συμπεριφορά της AI (Agent)
|
||||
|
||||
- `max_tool_iterations`: Πόσες φορές μπορεί η AI να χρησιμοποιήσει εργαλεία για να απαντήσει σε 1 μήνυμα (προεπιλογή: 10).
|
||||
- `max_history_messages`: Πόσα προηγούμενα μηνύματα θυμάται η AI στη συνομιλία (προεπιλογή: 50).
|
||||
|
||||
## 2. Αυτονομία και Ασφάλεια (Autonomy)
|
||||
|
||||
Εδώ ρυθμίζετε πόση ελευθερία έχει η AI να κάνει αλλαγές στον υπολογιστή σας.
|
||||
|
||||
- `level`:
|
||||
- `read_only`: Μπορεί μόνο να διαβάζει αρχεία.
|
||||
- `supervised`: Χρειάζεται την έγκρισή σας για σημαντικές ενέργειες (προεπιλογή).
|
||||
- `full`: Μπορεί να τρέχει εντολές ελεύθερα (προσοχή!).
|
||||
- `allowed_commands`: Λίστα με τις εντολές που επιτρέπεται να τρέχει η AI.
|
||||
- `forbidden_paths`: Φάκελοι που η AI **δεν** επιτρέπεται να αγγίξει (π.χ. `/etc`).
|
||||
|
||||
## 3. Μνήμη (Memory)
|
||||
|
||||
Πώς αποθηκεύει η AI τις πληροφορίες που της δίνετε.
|
||||
- `backend`: Μπορεί να είναι `sqlite` (βάση δεδομένων), `markdown` (απλά αρχεία κειμένου) ή `none` (καμία μνήμη).
|
||||
|
||||
## 4. Κανάλια Επικοινωνίας (Channels)
|
||||
|
||||
Κάθε κανάλι (Telegram, Discord κ.λπ.) έχει τη δική του ενότητα στο αρχείο.
|
||||
|
||||
Παράδειγμα για το **Telegram**:
|
||||
```toml
|
||||
[channels_config.telegram]
|
||||
bot_token = "το-κλειδί-σας"
|
||||
allowed_users = ["το-όνομά-σας"] # Ποιοι επιτρέπεται να μιλάνε στο bot
|
||||
```
|
||||
|
||||
## 5. Έλεγχος Κόστους (Cost)
|
||||
|
||||
Αν χρησιμοποιείτε πληρωμένες υπηρεσίες AI, μπορείτε να βάλετε όρια.
|
||||
- `daily_limit_usd`: Μέγιστο ποσό ανά ημέρα (π.χ. 10.00 δολάρια).
|
||||
- `monthly_limit_usd`: Μέγιστο ποσό ανά μήνα.
|
||||
|
||||
## 6. Εικόνες (Multimodal)
|
||||
|
||||
Ρυθμίσεις για το πώς η AI βλέπει εικόνες.
|
||||
- `max_images`: Μέγιστος αριθμός εικόνων ανά μήνυμα.
|
||||
- `allow_remote_fetch`: Αν επιτρέπεται στην AI να κατεβάζει εικόνες από το ίντερνετ μέσω συνδέσμων (links).
|
||||
|
||||
---
|
||||
|
||||
## Συμβουλές
|
||||
|
||||
- Αν αλλάξετε το αρχείο `config.toml`, πρέπει να κάνετε επανεκκίνηση το ZeroClaw για να δει τις αλλαγές.
|
||||
- Χρησιμοποιήστε την εντολή `zeroclaw doctor` για να βεβαιωθείτε ότι οι ρυθμίσεις σας είναι σωστές.
|
||||
|
||||
## Ενημέρωση (2026-03-03)
|
||||
|
||||
- Στην ενότητα `[agent]` προστέθηκαν τα `allowed_tools` και `denied_tools`.
|
||||
- Αν το `allowed_tools` δεν είναι κενό, ο primary agent βλέπει μόνο τα εργαλεία της λίστας.
|
||||
- Το `denied_tools` εφαρμόζεται μετά το allowlist και αφαιρεί επιπλέον εργαλεία.
|
||||
- Άγνωστες τιμές στο `allowed_tools` αγνοούνται (με debug log) και δεν μπλοκάρουν την εκκίνηση.
|
||||
- Αν `allowed_tools` και `denied_tools` καταλήξουν να αφαιρέσουν όλα τα εκτελέσιμα εργαλεία, η εκκίνηση αποτυγχάνει άμεσα με σαφές μήνυμα ρύθμισης.
|
||||
- Για πλήρη πίνακα πεδίων και παράδειγμα, δείτε το αγγλικό `config-reference.md` στην ενότητα `[agent]`.
|
||||
- Μην μοιράζεστε ποτέ το αρχείο `config.toml` με άλλους, καθώς περιέχει τα μυστικά κλειδιά σας (tokens).
|
||||
|
||||
## Ενημέρωση (2026-03-12)
|
||||
|
||||
- Στην ενότητα `[agent]` προστέθηκε το `tool_filter_groups` για φιλτράρισμα schema tool MCP ανά γύρο.
|
||||
|
||||
### `tool_filter_groups`
|
||||
|
||||
Μειώνει τα tokens ανά γύρο περιορίζοντας ποια schema tool MCP αποστέλλονται στο LLM. Τα ενσωματωμένα εργαλεία (χωρίς πρόθεμα `mcp_`) περνούν πάντα αναλλοίωτα.
|
||||
|
||||
Κάθε εγγραφή είναι πίνακας με τα εξής πεδία:
|
||||
|
||||
| Πεδίο | Τύπος | Σκοπός |
|
||||
|---|---|---|
|
||||
| `mode` | `"always"` \| `"dynamic"` | `always`: το εργαλείο συμπεριλαμβάνεται πάντα. `dynamic`: συμπεριλαμβάνεται μόνο όταν το μήνυμα χρήστη περιέχει λέξη-κλειδί. |
|
||||
| `tools` | `[string]` | Μοτίβα ονόματος εργαλείου. Υποστηρίζεται ένα `*` wildcard (π.χ. `"mcp_vikunja_*"`). |
|
||||
| `keywords` | `[string]` | (Μόνο για dynamic) Υποαλφαριθμητικά χωρίς διάκριση πεζών-κεφαλαίων που αντιστοιχούν στο τελευταίο μήνυμα χρήστη. |
|
||||
|
||||
Όταν το `tool_filter_groups` είναι κενό, η λειτουργία είναι ανενεργή και όλα τα εργαλεία περνούν κανονικά (συμβατό με προηγούμενες εκδόσεις).
|
||||
|
||||
Παράδειγμα:
|
||||
|
||||
```toml
|
||||
[agent]
|
||||
# Τα εργαλεία MCP Vikunja είναι πάντα διαθέσιμα.
|
||||
[[agent.tool_filter_groups]]
|
||||
mode = "always"
|
||||
tools = ["mcp_vikunja_*"]
|
||||
|
||||
# Τα εργαλεία MCP browser συμπεριλαμβάνονται μόνο όταν ο χρήστης αναφέρει πλοήγηση.
|
||||
[[agent.tool_filter_groups]]
|
||||
mode = "dynamic"
|
||||
tools = ["mcp_browser_*"]
|
||||
keywords = ["περιήγηση", "πλοήγηση", "άνοιγμα url", "στιγμιότυπο"]
|
||||
```
|
||||
@@ -1,94 +0,0 @@
|
||||
# Tài liệu ZeroClaw (Tiếng Việt)
|
||||
|
||||
Đây là trang chủ tiếng Việt của hệ thống tài liệu.
|
||||
|
||||
Đồng bộ lần cuối: **2026-02-21**.
|
||||
|
||||
> Lưu ý: Tên lệnh, khóa cấu hình và đường dẫn API giữ nguyên tiếng Anh. Khi có sai khác, tài liệu tiếng Anh là bản gốc.
|
||||
|
||||
## Tra cứu nhanh
|
||||
|
||||
| Tôi muốn… | Xem tài liệu |
|
||||
|---|---|
|
||||
| Cài đặt và chạy nhanh | [../../../README.vi.md](../../../README.vi.md) / [../../../README.md](../../../README.md) |
|
||||
| Cài đặt bằng một lệnh | [one-click-bootstrap.md](one-click-bootstrap.md) |
|
||||
| Tìm lệnh theo tác vụ | [commands-reference.md](commands-reference.md) |
|
||||
| Kiểm tra giá trị mặc định và khóa cấu hình | [config-reference.md](config-reference.md) |
|
||||
| Kết nối provider / endpoint tùy chỉnh | [custom-providers.md](custom-providers.md) |
|
||||
| Cấu hình Z.AI / GLM provider | [zai-glm-setup.md](zai-glm-setup.md) |
|
||||
| Sử dụng tích hợp LangGraph | [langgraph-integration.md](langgraph-integration.md) |
|
||||
| Vận hành hàng ngày (runbook) | [operations-runbook.md](operations-runbook.md) |
|
||||
| Khắc phục sự cố cài đặt/chạy/kênh | [troubleshooting.md](troubleshooting.md) |
|
||||
| Cấu hình Matrix phòng mã hóa (E2EE) | [matrix-e2ee-guide.md](matrix-e2ee-guide.md) |
|
||||
| Xem theo danh mục | [SUMMARY.md](SUMMARY.md) |
|
||||
| Xem bản chụp PR/Issue | [project-triage-snapshot-2026-02-18.md](../../maintainers/project-triage-snapshot-2026-02-18.md) |
|
||||
|
||||
## Tìm nhanh
|
||||
|
||||
- Cài đặt lần đầu hoặc khởi động nhanh → [getting-started/README.md](getting-started/README.md)
|
||||
- Cần tra cứu lệnh CLI / khóa cấu hình → [reference/README.md](reference/README.md)
|
||||
- Cần vận hành / triển khai sản phẩm → [operations/README.md](operations/README.md)
|
||||
- Gặp lỗi hoặc hồi quy → [troubleshooting.md](troubleshooting.md)
|
||||
- Tìm hiểu bảo mật và lộ trình → [security/README.md](security/README.md)
|
||||
- Làm việc với bo mạch / thiết bị ngoại vi → [hardware/README.md](hardware/README.md)
|
||||
- Đóng góp / review / quy trình CI → [contributing/README.md](contributing/README.md)
|
||||
- Xem toàn bộ bản đồ tài liệu → [SUMMARY.md](SUMMARY.md)
|
||||
|
||||
## Theo danh mục
|
||||
|
||||
- Bắt đầu: [getting-started/README.md](getting-started/README.md)
|
||||
- Tra cứu: [reference/README.md](reference/README.md)
|
||||
- Vận hành & triển khai: [operations/README.md](operations/README.md)
|
||||
- Bảo mật: [security/README.md](security/README.md)
|
||||
- Phần cứng & ngoại vi: [hardware/README.md](hardware/README.md)
|
||||
- Đóng góp & CI: [contributing/README.md](contributing/README.md)
|
||||
- Ảnh chụp dự án: [project/README.md](project/README.md)
|
||||
|
||||
## Theo vai trò
|
||||
|
||||
### Người dùng / Vận hành
|
||||
|
||||
- [commands-reference.md](commands-reference.md) — tra cứu lệnh theo tác vụ
|
||||
- [providers-reference.md](providers-reference.md) — ID provider, bí danh, biến môi trường xác thực
|
||||
- [channels-reference.md](channels-reference.md) — khả năng kênh và hướng dẫn thiết lập
|
||||
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md) — thiết lập phòng mã hóa Matrix (E2EE)
|
||||
- [config-reference.md](config-reference.md) — khóa cấu hình quan trọng và giá trị mặc định an toàn
|
||||
- [custom-providers.md](custom-providers.md) — mẫu tích hợp provider / base URL tùy chỉnh
|
||||
- [zai-glm-setup.md](zai-glm-setup.md) — thiết lập Z.AI/GLM và ma trận endpoint
|
||||
- [langgraph-integration.md](langgraph-integration.md) — tích hợp dự phòng cho model/tool-calling
|
||||
- [operations-runbook.md](operations-runbook.md) — vận hành runtime hàng ngày và quy trình rollback
|
||||
- [troubleshooting.md](troubleshooting.md) — dấu hiệu lỗi thường gặp và cách khắc phục
|
||||
|
||||
### Người đóng góp / Bảo trì
|
||||
|
||||
- [CONTRIBUTING.md](../../../CONTRIBUTING.md)
|
||||
- [pr-workflow.md](pr-workflow.md)
|
||||
- [reviewer-playbook.md](reviewer-playbook.md)
|
||||
- [ci-map.md](ci-map.md)
|
||||
- [actions-source-policy.md](actions-source-policy.md)
|
||||
|
||||
### Bảo mật / Độ tin cậy
|
||||
|
||||
> Lưu ý: Mục này gồm tài liệu đề xuất/lộ trình, có thể chứa lệnh hoặc cấu hình chưa triển khai. Để biết hành vi thực tế, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md) và [troubleshooting.md](troubleshooting.md) trước.
|
||||
|
||||
- [security/README.md](security/README.md)
|
||||
- [agnostic-security.md](agnostic-security.md)
|
||||
- [frictionless-security.md](frictionless-security.md)
|
||||
- [sandboxing.md](sandboxing.md)
|
||||
- [audit-logging.md](audit-logging.md)
|
||||
- [resource-limits.md](resource-limits.md)
|
||||
- [security-roadmap.md](security-roadmap.md)
|
||||
|
||||
## Quản lý tài liệu
|
||||
|
||||
- Mục lục thống nhất (TOC): [SUMMARY.md](SUMMARY.md)
|
||||
- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [../../maintainers/structure-README.md](../../maintainers/structure-README.md)
|
||||
- Danh mục và phân loại tài liệu: [docs-inventory.md](../../maintainers/docs-inventory.md)
|
||||
|
||||
## Ngôn ngữ khác
|
||||
|
||||
- English: [README.md](../../README.md)
|
||||
- 简体中文: [README.zh-CN.md](../../README.zh-CN.md)
|
||||
- 日本語: [README.ja.md](../../README.ja.md)
|
||||
- Русский: [README.ru.md](../../README.ru.md)
|
||||
- Français: [README.fr.md](../../README.fr.md)
|
||||
@@ -1,78 +0,0 @@
|
||||
# Mục lục tài liệu ZeroClaw (Tiếng Việt)
|
||||
|
||||
Đây là mục lục thống nhất cho hệ thống tài liệu tiếng Việt.
|
||||
|
||||
Đồng bộ lần cuối: **2026-02-21**.
|
||||
|
||||
## Điểm vào
|
||||
|
||||
- Bản đồ cấu trúc docs (ngôn ngữ/phần/chức năng): [../../maintainers/structure-README.md](../../maintainers/structure-README.md)
|
||||
- README tiếng Việt: [../../../README.vi.md](../../../README.vi.md)
|
||||
- Docs hub tiếng Việt: [README.md](README.md)
|
||||
|
||||
## Danh mục
|
||||
|
||||
### 1) Bắt đầu
|
||||
|
||||
- [getting-started/README.md](getting-started/README.md)
|
||||
- [one-click-bootstrap.md](one-click-bootstrap.md)
|
||||
|
||||
### 2) Lệnh / Cấu hình / Tích hợp
|
||||
|
||||
- [reference/README.md](reference/README.md)
|
||||
- [commands-reference.md](commands-reference.md)
|
||||
- [providers-reference.md](providers-reference.md)
|
||||
- [channels-reference.md](channels-reference.md)
|
||||
- [config-reference.md](config-reference.md)
|
||||
- [custom-providers.md](custom-providers.md)
|
||||
- [zai-glm-setup.md](zai-glm-setup.md)
|
||||
- [langgraph-integration.md](langgraph-integration.md)
|
||||
|
||||
### 3) Vận hành & Triển khai
|
||||
|
||||
- [operations/README.md](operations/README.md)
|
||||
- [operations-runbook.md](operations-runbook.md)
|
||||
- [release-process.md](release-process.md)
|
||||
- [troubleshooting.md](troubleshooting.md)
|
||||
- [network-deployment.md](network-deployment.md)
|
||||
- [mattermost-setup.md](mattermost-setup.md)
|
||||
- [matrix-e2ee-guide.md](matrix-e2ee-guide.md)
|
||||
|
||||
### 4) Bảo mật
|
||||
|
||||
- [security/README.md](security/README.md)
|
||||
- [agnostic-security.md](agnostic-security.md)
|
||||
- [frictionless-security.md](frictionless-security.md)
|
||||
- [sandboxing.md](sandboxing.md)
|
||||
- [resource-limits.md](resource-limits.md)
|
||||
- [audit-logging.md](audit-logging.md)
|
||||
- [security-roadmap.md](security-roadmap.md)
|
||||
|
||||
### 5) Phần cứng & Ngoại vi
|
||||
|
||||
- [hardware/README.md](hardware/README.md)
|
||||
- [hardware-peripherals-design.md](hardware-peripherals-design.md)
|
||||
- [adding-boards-and-tools.md](adding-boards-and-tools.md)
|
||||
- [nucleo-setup.md](nucleo-setup.md)
|
||||
- [arduino-uno-q-setup.md](arduino-uno-q-setup.md)
|
||||
- [datasheets/nucleo-f401re.md](datasheets/nucleo-f401re.md)
|
||||
- [datasheets/arduino-uno.md](datasheets/arduino-uno.md)
|
||||
- [datasheets/esp32.md](datasheets/esp32.md)
|
||||
|
||||
### 6) Đóng góp & CI
|
||||
|
||||
- [contributing/README.md](contributing/README.md)
|
||||
- [CONTRIBUTING.md](../../../CONTRIBUTING.md)
|
||||
- [pr-workflow.md](pr-workflow.md)
|
||||
- [reviewer-playbook.md](reviewer-playbook.md)
|
||||
- [ci-map.md](ci-map.md)
|
||||
- [actions-source-policy.md](actions-source-policy.md)
|
||||
|
||||
### 7) Dự án
|
||||
|
||||
- [project/README.md](project/README.md)
|
||||
- [proxy-agent-playbook.md](proxy-agent-playbook.md)
|
||||
|
||||
## Ngôn ngữ khác
|
||||
|
||||
- English TOC: [../../SUMMARY.md](../../SUMMARY.md)
|
||||
@@ -1,95 +0,0 @@
|
||||
# Chính sách nguồn Actions (Giai đoạn 1)
|
||||
|
||||
Tài liệu này định nghĩa chính sách kiểm soát nguồn GitHub Actions hiện tại cho repository này.
|
||||
|
||||
Mục tiêu Giai đoạn 1: khóa nguồn action với ít gián đoạn nhất, trước khi pin SHA đầy đủ.
|
||||
|
||||
## Chính sách hiện tại
|
||||
|
||||
- Quyền Actions repository: được bật
|
||||
- Chế độ action cho phép: đã chọn
|
||||
- Yêu cầu pin SHA: false (hoãn đến Giai đoạn 2)
|
||||
|
||||
Các mẫu allowlist được chọn:
|
||||
|
||||
- `actions/*` (bao gồm `actions/cache`, `actions/checkout`, `actions/upload-artifact`, `actions/download-artifact` và các first-party action khác)
|
||||
- `docker/*`
|
||||
- `dtolnay/rust-toolchain@*`
|
||||
- `DavidAnson/markdownlint-cli2-action@*`
|
||||
- `lycheeverse/lychee-action@*`
|
||||
- `EmbarkStudios/cargo-deny-action@*`
|
||||
- `rustsec/audit-check@*`
|
||||
- `rhysd/actionlint@*`
|
||||
- `softprops/action-gh-release@*`
|
||||
- `sigstore/cosign-installer@*`
|
||||
- `useblacksmith/*` (cơ sở hạ tầng self-hosted runner Blacksmith)
|
||||
|
||||
## Xuất kiểm soát thay đổi
|
||||
|
||||
Dùng các lệnh sau để xuất chính sách hiệu lực hiện tại phục vụ kiểm toán/kiểm soát thay đổi:
|
||||
|
||||
```bash
|
||||
gh api repos/zeroclaw-labs/zeroclaw/actions/permissions
|
||||
gh api repos/zeroclaw-labs/zeroclaw/actions/permissions/selected-actions
|
||||
```
|
||||
|
||||
Ghi lại mỗi thay đổi chính sách với:
|
||||
|
||||
- ngày/giờ thay đổi (UTC)
|
||||
- tác nhân
|
||||
- lý do
|
||||
- delta allowlist (mẫu được thêm/xóa)
|
||||
- ghi chú rollback
|
||||
|
||||
## Lý do giai đoạn này
|
||||
|
||||
- Giảm rủi ro chuỗi cung ứng từ các marketplace action chưa được review.
|
||||
- Bảo tồn chức năng CI/CD hiện tại với chi phí migration thấp.
|
||||
- Chuẩn bị cho Giai đoạn 2 pin SHA đầy đủ mà không chặn phát triển đang diễn ra.
|
||||
|
||||
## Bảo vệ workflow agentic
|
||||
|
||||
Vì repository này có khối lượng thay đổi do agent tạo ra cao:
|
||||
|
||||
- Mọi PR thêm hoặc thay đổi nguồn action `uses:` phải bao gồm ghi chú tác động allowlist.
|
||||
- Các action bên thứ ba mới yêu cầu review maintainer tường minh trước khi đưa vào allowlist.
|
||||
- Chỉ mở rộng allowlist cho các action bị thiếu đã được xác minh; tránh các ngoại lệ wildcard rộng.
|
||||
- Giữ hướng dẫn rollback trong mô tả PR cho các thay đổi chính sách Actions.
|
||||
|
||||
## Checklist xác thực
|
||||
|
||||
Sau khi thay đổi allowlist, xác thực:
|
||||
|
||||
1. `CI`
|
||||
2. `Docker`
|
||||
3. `Security Audit`
|
||||
4. `Workflow Sanity`
|
||||
5. `Release` (khi an toàn để chạy)
|
||||
|
||||
Failure mode cần chú ý:
|
||||
|
||||
- `action is not allowed by policy`
|
||||
|
||||
Nếu gặp phải, chỉ thêm action tin cậy còn thiếu cụ thể đó, chạy lại và ghi lại lý do.
|
||||
|
||||
Ghi chú quét gần đây nhất:
|
||||
|
||||
- 2026-02-17: Cache phụ thuộc Rust được migrate từ `Swatinem/rust-cache` sang `useblacksmith/rust-cache`
|
||||
- Không cần mẫu allowlist mới (`useblacksmith/*` đã có trong allowlist)
|
||||
- 2026-02-16: Phụ thuộc ẩn được phát hiện trong `release-beta-on-push.yml`: `sigstore/cosign-installer@...`
|
||||
- Đã thêm mẫu allowlist: `sigstore/cosign-installer@*`
|
||||
- 2026-02-16: Migration Blacksmith chặn thực thi workflow
|
||||
- Đã thêm mẫu allowlist: `useblacksmith/*` cho cơ sở hạ tầng self-hosted runner
|
||||
- Actions: `useblacksmith/setup-docker-builder@v1`, `useblacksmith/build-push-action@v2`
|
||||
- 2026-02-17: Cập nhật cân bằng tính tái tạo/độ tươi của security audit
|
||||
- Đã thêm mẫu allowlist: `rustsec/audit-check@*`
|
||||
- Thay thế thực thi nội tuyến `cargo install cargo-audit` bằng `rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998` được pin trong `security.yml`
|
||||
- Supersedes đề xuất phiên bản nổi trong #588 trong khi giữ chính sách nguồn action rõ ràng
|
||||
|
||||
## Rollback
|
||||
|
||||
Đường dẫn bỏ chặn khẩn cấp:
|
||||
|
||||
1. Tạm thời đặt chính sách Actions trở về `all`.
|
||||
2. Khôi phục allowlist đã chọn sau khi xác định các mục còn thiếu.
|
||||
3. Ghi lại sự cố và delta allowlist cuối cùng.
|
||||
@@ -1,116 +0,0 @@
|
||||
# Thêm Board và Tool — Hướng dẫn phần cứng ZeroClaw
|
||||
|
||||
Hướng dẫn này giải thích cách thêm board phần cứng mới và tool tùy chỉnh vào ZeroClaw.
|
||||
|
||||
## Bắt đầu nhanh: Thêm board qua CLI
|
||||
|
||||
```bash
|
||||
# Thêm board (cập nhật ~/.zeroclaw/config.toml)
|
||||
zeroclaw peripheral add nucleo-f401re /dev/ttyACM0
|
||||
zeroclaw peripheral add arduino-uno /dev/cu.usbmodem12345
|
||||
zeroclaw peripheral add rpi-gpio native # cho Raspberry Pi GPIO (Linux)
|
||||
|
||||
# Khởi động lại daemon để áp dụng
|
||||
zeroclaw daemon --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
## Các board được hỗ trợ
|
||||
|
||||
| Board | Transport | Ví dụ đường dẫn |
|
||||
|-------|-----------|-----------------|
|
||||
| nucleo-f401re | serial | /dev/ttyACM0, /dev/cu.usbmodem* |
|
||||
| arduino-uno | serial | /dev/ttyACM0, /dev/cu.usbmodem* |
|
||||
| arduino-uno-q | bridge | (IP của Uno Q) |
|
||||
| rpi-gpio | native | native |
|
||||
| esp32 | serial | /dev/ttyUSB0 |
|
||||
|
||||
## Cấu hình thủ công
|
||||
|
||||
Chỉnh sửa `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
datasheet_dir = "docs/datasheets" # tùy chọn: RAG cho "turn on red led" → pin 13
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "arduino-uno"
|
||||
transport = "serial"
|
||||
path = "/dev/cu.usbmodem12345"
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
## Thêm Datasheet (RAG)
|
||||
|
||||
Đặt file `.md` hoặc `.txt` vào `docs/datasheets/` (hoặc `datasheet_dir` của bạn). Đặt tên file theo board: `nucleo-f401re.md`, `arduino-uno.md`.
|
||||
|
||||
### Pin Aliases (Khuyến nghị)
|
||||
|
||||
Thêm mục `## Pin Aliases` để agent có thể ánh xạ "red led" → pin 13:
|
||||
|
||||
```markdown
|
||||
# My Board
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| red_led | 13 |
|
||||
| builtin_led | 13 |
|
||||
| user_led | 5 |
|
||||
```
|
||||
|
||||
Hoặc dùng định dạng key-value:
|
||||
|
||||
```markdown
|
||||
## Pin Aliases
|
||||
red_led: 13
|
||||
builtin_led: 13
|
||||
```
|
||||
|
||||
### PDF Datasheets
|
||||
|
||||
Với feature `rag-pdf`, ZeroClaw có thể lập chỉ mục file PDF:
|
||||
|
||||
```bash
|
||||
cargo build --features hardware,rag-pdf
|
||||
```
|
||||
|
||||
Đặt file PDF vào thư mục datasheet. Chúng sẽ được trích xuất và chia nhỏ thành các đoạn cho RAG.
|
||||
|
||||
## Thêm loại board mới
|
||||
|
||||
1. **Tạo datasheet** — `docs/datasheets/my-board.md` với pin aliases và thông tin GPIO.
|
||||
2. **Thêm vào config** — `zeroclaw peripheral add my-board /dev/ttyUSB0`
|
||||
3. **Triển khai peripheral** (tùy chọn) — Với giao thức tùy chỉnh, hãy implement trait `Peripheral` trong `src/peripherals/` và đăng ký trong `create_peripheral_tools`.
|
||||
|
||||
Xem `docs/hardware-peripherals-design.md` để hiểu toàn bộ thiết kế.
|
||||
|
||||
## Thêm Tool tùy chỉnh
|
||||
|
||||
1. Implement trait `Tool` trong `src/tools/`.
|
||||
2. Đăng ký trong `create_peripheral_tools` (với hardware tool) hoặc tool registry của agent.
|
||||
3. Thêm mô tả tool vào `tool_descs` của agent trong `src/agent/loop_.rs`.
|
||||
|
||||
## Tham chiếu CLI
|
||||
|
||||
| Lệnh | Mô tả |
|
||||
|------|-------|
|
||||
| `zeroclaw peripheral list` | Liệt kê các board đã cấu hình |
|
||||
| `zeroclaw peripheral add <board> <path>` | Thêm board (ghi vào config) |
|
||||
| `zeroclaw peripheral flash` | Nạp firmware Arduino |
|
||||
| `zeroclaw peripheral flash-nucleo` | Nạp firmware Nucleo |
|
||||
| `zeroclaw hardware discover` | Liệt kê thiết bị USB |
|
||||
| `zeroclaw hardware info` | Thông tin chip qua probe-rs |
|
||||
|
||||
## Xử lý sự cố
|
||||
|
||||
- **Không tìm thấy serial port** — Trên macOS dùng `/dev/cu.usbmodem*`; trên Linux dùng `/dev/ttyACM0` hoặc `/dev/ttyUSB0`.
|
||||
- **Build với hardware** — `cargo build --features hardware`
|
||||
- **probe-rs cho Nucleo** — `cargo build --features hardware,probe`
|
||||
@@ -1,355 +0,0 @@
|
||||
# Bảo mật không phụ thuộc nền tảng
|
||||
|
||||
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
|
||||
>
|
||||
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
|
||||
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Câu hỏi cốt lõi: liệu các tính năng bảo mật có làm hỏng
|
||||
|
||||
1. ❓ Quá trình cross-compilation nhanh?
|
||||
2. ❓ Kiến trúc pluggable (hoán đổi bất kỳ thành phần nào)?
|
||||
3. ❓ Tính agnostic phần cứng (ARM, x86, RISC-V)?
|
||||
4. ❓ Hỗ trợ phần cứng nhỏ (<5MB RAM, board $10)?
|
||||
|
||||
**Câu trả lời: KHÔNG với tất cả** — Bảo mật được thiết kế dưới dạng **feature flags tùy chọn** với **conditional compilation theo từng nền tảng**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tốc độ build: bảo mật ẩn sau feature flag
|
||||
|
||||
### Cargo.toml: các tính năng bảo mật đặt sau features
|
||||
|
||||
```toml
|
||||
[features]
|
||||
default = ["basic-security"]
|
||||
|
||||
# Basic security (luôn bật, không tốn overhead)
|
||||
basic-security = []
|
||||
|
||||
# Platform-specific sandboxing (opt-in theo từng nền tảng)
|
||||
sandbox-landlock = [] # Chỉ Linux
|
||||
sandbox-firejail = [] # Chỉ Linux
|
||||
sandbox-bubblewrap = []# macOS/Linux
|
||||
sandbox-docker = [] # Tất cả nền tảng (nặng)
|
||||
|
||||
# Bộ bảo mật đầy đủ (dành cho production build)
|
||||
security-full = [
|
||||
"basic-security",
|
||||
"sandbox-landlock",
|
||||
"resource-monitoring",
|
||||
"audit-logging",
|
||||
]
|
||||
|
||||
# Resource & audit monitoring
|
||||
resource-monitoring = []
|
||||
audit-logging = []
|
||||
|
||||
# Development build (nhanh nhất, không phụ thuộc thêm)
|
||||
dev = []
|
||||
```
|
||||
|
||||
### Lệnh build (chọn profile phù hợp)
|
||||
|
||||
```bash
|
||||
# Dev build cực nhanh (không có extras bảo mật)
|
||||
cargo build --profile dev
|
||||
|
||||
# Release build với basic security (mặc định)
|
||||
cargo build --release
|
||||
# → Bao gồm: allowlist, path blocking, injection protection
|
||||
# → Không bao gồm: Landlock, Firejail, audit logging
|
||||
|
||||
# Production build với full security
|
||||
cargo build --release --features security-full
|
||||
# → Bao gồm: Tất cả
|
||||
|
||||
# Chỉ sandbox theo nền tảng cụ thể
|
||||
cargo build --release --features sandbox-landlock # Linux
|
||||
cargo build --release --features sandbox-docker # Tất cả nền tảng
|
||||
```
|
||||
|
||||
### Conditional compilation: không overhead khi tắt
|
||||
|
||||
```rust
|
||||
// src/security/mod.rs
|
||||
|
||||
#[cfg(feature = "sandbox-landlock")]
|
||||
mod landlock;
|
||||
#[cfg(feature = "sandbox-landlock")]
|
||||
pub use landlock::LandlockSandbox;
|
||||
|
||||
#[cfg(feature = "sandbox-firejail")]
|
||||
mod firejail;
|
||||
#[cfg(feature = "sandbox-firejail")]
|
||||
pub use firejail::FirejailSandbox;
|
||||
|
||||
// Basic security luôn được include (không cần feature flag)
|
||||
pub mod policy; // allowlist, path blocking, injection protection
|
||||
```
|
||||
|
||||
**Kết quả**: Khi các feature bị tắt, code thậm chí không được biên dịch — **binary hoàn toàn không bị phình to**.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kiến trúc pluggable: bảo mật cũng là một trait
|
||||
|
||||
### Security backend trait (hoán đổi như mọi thứ khác)
|
||||
|
||||
```rust
|
||||
// src/security/traits.rs
|
||||
|
||||
#[async_trait]
|
||||
pub trait Sandbox: Send + Sync {
|
||||
/// Bọc lệnh với lớp bảo vệ sandbox
|
||||
fn wrap_command(&self, cmd: &mut std::process::Command) -> std::io::Result<()>;
|
||||
|
||||
/// Kiểm tra sandbox có khả dụng trên nền tảng này không
|
||||
fn is_available(&self) -> bool;
|
||||
|
||||
/// Tên dễ đọc
|
||||
fn name(&self) -> &str;
|
||||
}
|
||||
|
||||
// No-op sandbox (luôn khả dụng)
|
||||
pub struct NoopSandbox;
|
||||
|
||||
impl Sandbox for NoopSandbox {
|
||||
fn wrap_command(&self, _cmd: &mut std::process::Command) -> std::io::Result<()> {
|
||||
Ok(()) // Pass-through, không thay đổi
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool { true }
|
||||
fn name(&self) -> &str { "none" }
|
||||
}
|
||||
```
|
||||
|
||||
### Factory pattern: tự động chọn dựa trên features
|
||||
|
||||
```rust
|
||||
// src/security/factory.rs
|
||||
|
||||
pub fn create_sandbox() -> Box<dyn Sandbox> {
|
||||
#[cfg(feature = "sandbox-landlock")]
|
||||
{
|
||||
if LandlockSandbox::is_available() {
|
||||
return Box::new(LandlockSandbox::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sandbox-firejail")]
|
||||
{
|
||||
if FirejailSandbox::is_available() {
|
||||
return Box::new(FirejailSandbox::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sandbox-bubblewrap")]
|
||||
{
|
||||
if BubblewrapSandbox::is_available() {
|
||||
return Box::new(BubblewrapSandbox::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "sandbox-docker")]
|
||||
{
|
||||
if DockerSandbox::is_available() {
|
||||
return Box::new(DockerSandbox::new());
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: luôn khả dụng
|
||||
Box::new(NoopSandbox)
|
||||
}
|
||||
```
|
||||
|
||||
**Giống như providers, channels và memory — bảo mật cũng là pluggable!**
|
||||
|
||||
---
|
||||
|
||||
## 3. Agnostic phần cứng: cùng binary, nhiều nền tảng
|
||||
|
||||
### Ma trận hành vi đa nền tảng
|
||||
|
||||
| Nền tảng | Build trên | Hành vi runtime |
|
||||
|----------|-----------|------------------|
|
||||
| **Linux ARM** (Raspberry Pi) | ✅ Có | Landlock → None (graceful) |
|
||||
| **Linux x86_64** | ✅ Có | Landlock → Firejail → None |
|
||||
| **macOS ARM** (M1/M2) | ✅ Có | Bubblewrap → None |
|
||||
| **macOS x86_64** | ✅ Có | Bubblewrap → None |
|
||||
| **Windows ARM** | ✅ Có | None (app-layer) |
|
||||
| **Windows x86_64** | ✅ Có | None (app-layer) |
|
||||
| **RISC-V Linux** | ✅ Có | Landlock → None |
|
||||
|
||||
### Cơ chế hoạt động: phát hiện tại runtime
|
||||
|
||||
```rust
|
||||
// src/security/detect.rs
|
||||
|
||||
impl SandboxingStrategy {
|
||||
/// Chọn sandbox tốt nhất có sẵn TẠI RUNTIME
|
||||
pub fn detect() -> SandboxingStrategy {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Thử Landlock trước (phát hiện tính năng kernel)
|
||||
if Self::probe_landlock() {
|
||||
return SandboxingStrategy::Landlock;
|
||||
}
|
||||
|
||||
// Thử Firejail (phát hiện công cụ user-space)
|
||||
if Self::probe_firejail() {
|
||||
return SandboxingStrategy::Firejail;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if Self::probe_bubblewrap() {
|
||||
return SandboxingStrategy::Bubblewrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback luôn khả dụng
|
||||
SandboxingStrategy::ApplicationLayer
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cùng một binary chạy ở khắp nơi** — chỉ tự điều chỉnh mức độ bảo vệ dựa trên những gì có sẵn.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phần cứng nhỏ: phân tích tác động bộ nhớ
|
||||
|
||||
### Tác động kích thước binary (ước tính)
|
||||
|
||||
| Tính năng | Kích thước code | RAM overhead | Trạng thái |
|
||||
|---------|-----------|--------------|--------|
|
||||
| **ZeroClaw cơ bản** | 3.4MB | <5MB | ✅ Hiện tại |
|
||||
| **+ Landlock** | +50KB | +100KB | ✅ Linux 5.13+ |
|
||||
| **+ Firejail wrapper** | +20KB | +0KB (external) | ✅ Linux + firejail |
|
||||
| **+ Memory monitoring** | +30KB | +50KB | ✅ Tất cả nền tảng |
|
||||
| **+ Audit logging** | +40KB | +200KB (buffered) | ✅ Tất cả nền tảng |
|
||||
| **Full security** | +140KB | +350KB | ✅ Vẫn <6MB tổng |
|
||||
|
||||
### Tương thích phần cứng $10
|
||||
|
||||
| Phần cứng | RAM | ZeroClaw (cơ bản) | ZeroClaw (full security) | Trạng thái |
|
||||
|----------|-----|-----------------|--------------------------|--------|
|
||||
| **Raspberry Pi Zero** | 512MB | ✅ 2% | ✅ 2.5% | Hoạt động |
|
||||
| **Orange Pi Zero** | 512MB | ✅ 2% | ✅ 2.5% | Hoạt động |
|
||||
| **NanoPi NEO** | 256MB | ✅ 4% | ✅ 5% | Hoạt động |
|
||||
| **C.H.I.P.** | 512MB | ✅ 2% | ✅ 2.5% | Hoạt động |
|
||||
| **Rock64** | 1GB | ✅ 1% | ✅ 1.2% | Hoạt động |
|
||||
|
||||
**Ngay cả với full security, ZeroClaw chỉ dùng <5% RAM trên board $10.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Tính hoán đổi: mọi thứ vẫn pluggable
|
||||
|
||||
### Cam kết chính của ZeroClaw: hoán đổi bất kỳ thứ gì
|
||||
|
||||
```rust
|
||||
// Providers (đã pluggable)
|
||||
Box<dyn Provider>
|
||||
|
||||
// Channels (đã pluggable)
|
||||
Box<dyn Channel>
|
||||
|
||||
// Memory (đã pluggable)
|
||||
Box<dyn MemoryBackend>
|
||||
|
||||
// Tunnels (đã pluggable)
|
||||
Box<dyn Tunnel>
|
||||
|
||||
// BÂY GIỜ CŨNG: Security (mới pluggable)
|
||||
Box<dyn Sandbox>
|
||||
Box<dyn Auditor>
|
||||
Box<dyn ResourceMonitor>
|
||||
```
|
||||
|
||||
### Hoán đổi security backend qua config
|
||||
|
||||
```toml
|
||||
# Không dùng sandbox (nhanh nhất, chỉ app-layer)
|
||||
[security.sandbox]
|
||||
backend = "none"
|
||||
|
||||
# Dùng Landlock (Linux kernel LSM, native)
|
||||
[security.sandbox]
|
||||
backend = "landlock"
|
||||
|
||||
# Dùng Firejail (user-space, cần cài firejail)
|
||||
[security.sandbox]
|
||||
backend = "firejail"
|
||||
|
||||
# Dùng Docker (nặng nhất, cách ly hoàn toàn)
|
||||
[security.sandbox]
|
||||
backend = "docker"
|
||||
```
|
||||
|
||||
**Giống như hoán đổi OpenAI sang Gemini, hay SQLite sang PostgreSQL.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Tác động phụ thuộc: thêm tối thiểu
|
||||
|
||||
### Phụ thuộc hiện tại (để tham khảo)
|
||||
|
||||
```
|
||||
reqwest, tokio, serde, anyhow, uuid, chrono, rusqlite,
|
||||
axum, tracing, opentelemetry, ...
|
||||
```
|
||||
|
||||
### Phụ thuộc của các security feature
|
||||
|
||||
| Tính năng | Phụ thuộc mới | Nền tảng |
|
||||
|---------|------------------|----------|
|
||||
| **Landlock** | `landlock` crate (pure Rust) | Chỉ Linux |
|
||||
| **Firejail** | Không (binary ngoài) | Chỉ Linux |
|
||||
| **Bubblewrap** | Không (binary ngoài) | macOS/Linux |
|
||||
| **Docker** | `bollard` crate (Docker API) | Tất cả nền tảng |
|
||||
| **Memory monitoring** | Không (std::alloc) | Tất cả nền tảng |
|
||||
| **Audit logging** | Không (đã có hmac/sha2) | Tất cả nền tảng |
|
||||
|
||||
**Kết quả**: Hầu hết tính năng **không thêm phụ thuộc Rust mới** — chúng hoặc:
|
||||
1. Dùng pure-Rust crate (landlock)
|
||||
2. Bọc binary ngoài (Firejail, Bubblewrap)
|
||||
3. Dùng phụ thuộc sẵn có (hmac, sha2 đã có trong Cargo.toml)
|
||||
|
||||
---
|
||||
|
||||
## Tóm tắt: các giá trị chính được bảo toàn
|
||||
|
||||
| Giá trị | Trước | Sau (có bảo mật) | Trạng thái |
|
||||
|------------|--------|----------------------|--------|
|
||||
| **<5MB RAM** | ✅ <5MB | ✅ <6MB (trường hợp xấu nhất) | ✅ Bảo toàn |
|
||||
| **<10ms startup** | ✅ <10ms | ✅ <15ms (detection) | ✅ Bảo toàn |
|
||||
| **3.4MB binary** | ✅ 3.4MB | ✅ 3.5MB (với tất cả features) | ✅ Bảo toàn |
|
||||
| **ARM + x86 + RISC-V** | ✅ Tất cả | ✅ Tất cả | ✅ Bảo toàn |
|
||||
| **Phần cứng $10** | ✅ Hoạt động | ✅ Hoạt động | ✅ Bảo toàn |
|
||||
| **Pluggable everything** | ✅ Có | ✅ Có (cả bảo mật) | ✅ Cải thiện |
|
||||
| **Cross-platform** | ✅ Có | ✅ Có | ✅ Bảo toàn |
|
||||
|
||||
---
|
||||
|
||||
## Điểm mấu chốt: feature flags + conditional compilation
|
||||
|
||||
```bash
|
||||
# Developer build (nhanh nhất, không có extra feature)
|
||||
cargo build --profile dev
|
||||
|
||||
# Standard release (build hiện tại của bạn)
|
||||
cargo build --release
|
||||
|
||||
# Production với full security
|
||||
cargo build --release --features security-full
|
||||
|
||||
# Nhắm đến phần cứng cụ thể
|
||||
cargo build --release --target aarch64-unknown-linux-gnu # Raspberry Pi
|
||||
cargo build --release --target riscv64gc-unknown-linux-gnu # RISC-V
|
||||
cargo build --release --target armv7-unknown-linux-gnueabihf # ARMv7
|
||||
```
|
||||
|
||||
**Mọi target, mọi nền tảng, mọi trường hợp sử dụng — vẫn nhanh, vẫn nhỏ, vẫn agnostic.**
|
||||
@@ -1,217 +0,0 @@
|
||||
# ZeroClaw trên Arduino Uno Q — Hướng dẫn từng bước
|
||||
|
||||
Chạy ZeroClaw trên phía Linux của Arduino Uno Q. Telegram hoạt động qua WiFi; điều khiển GPIO dùng Bridge (yêu cầu một ứng dụng App Lab tối giản).
|
||||
|
||||
---
|
||||
|
||||
## Những gì đã có sẵn (Không cần thay đổi code)
|
||||
|
||||
ZeroClaw bao gồm mọi thứ cần thiết cho Arduino Uno Q. **Clone repo và làm theo hướng dẫn này — không cần patch hay code tùy chỉnh nào.**
|
||||
|
||||
| Thành phần | Vị trí | Mục đích |
|
||||
|------------|--------|---------|
|
||||
| Bridge app | `firmware/uno-q-bridge/` | MCU sketch + Python socket server (port 9999) cho GPIO |
|
||||
| Bridge tools | `src/peripherals/uno_q_bridge.rs` | Tool `gpio_read` / `gpio_write` giao tiếp với Bridge qua TCP |
|
||||
| Setup command | `src/peripherals/uno_q_setup.rs` | `zeroclaw peripheral setup-uno-q` triển khai Bridge qua scp + arduino-app-cli |
|
||||
| Config schema | `board = "arduino-uno-q"`, `transport = "bridge"` | Được hỗ trợ trong `config.toml` |
|
||||
|
||||
Build với `--features hardware` (hoặc features mặc định) để bao gồm hỗ trợ Uno Q.
|
||||
|
||||
---
|
||||
|
||||
## Yêu cầu trước khi bắt đầu
|
||||
|
||||
- Arduino Uno Q đã cấu hình WiFi
|
||||
- Arduino App Lab đã cài trên Mac (để thiết lập và triển khai lần đầu)
|
||||
- API key cho LLM (OpenRouter, v.v.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Thiết lập Uno Q lần đầu (Một lần duy nhất)
|
||||
|
||||
### 1.1 Cấu hình Uno Q qua App Lab
|
||||
|
||||
1. Tải [Arduino App Lab](https://docs.arduino.cc/software/app-lab/) (AppImage trên Linux).
|
||||
2. Kết nối Uno Q qua USB, bật nguồn.
|
||||
3. Mở App Lab, kết nối với board.
|
||||
4. Làm theo hướng dẫn cài đặt:
|
||||
- Đặt username và password (cho SSH)
|
||||
- Cấu hình WiFi (SSID, password)
|
||||
- Áp dụng các bản cập nhật firmware nếu có
|
||||
5. Ghi lại địa chỉ IP hiển thị (ví dụ: `arduino@192.168.1.42`) hoặc tìm sau qua `ip addr show` trong terminal của App Lab.
|
||||
|
||||
### 1.2 Xác nhận truy cập SSH
|
||||
|
||||
```bash
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
# Nhập password đã đặt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Cài đặt ZeroClaw trên Uno Q
|
||||
|
||||
### Phương án A: Build trực tiếp trên thiết bị (Đơn giản hơn, ~20–40 phút)
|
||||
|
||||
```bash
|
||||
# SSH vào Uno Q
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
|
||||
# Cài Rust
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source ~/.cargo/env
|
||||
|
||||
# Cài các gói phụ thuộc build (Debian)
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libssl-dev
|
||||
|
||||
# Clone zeroclaw (hoặc scp project của bạn)
|
||||
git clone https://github.com/zeroclaw-labs/zeroclaw.git
|
||||
cd zeroclaw
|
||||
|
||||
# Build (~15–30 phút trên Uno Q)
|
||||
cargo build --release
|
||||
|
||||
# Cài đặt
|
||||
sudo cp target/release/zeroclaw /usr/local/bin/
|
||||
```
|
||||
|
||||
### Phương án B: Cross-Compile trên Mac (Nhanh hơn)
|
||||
|
||||
```bash
|
||||
# Trên Mac — thêm target aarch64
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
# Cài cross-compiler (macOS; cần cho linking)
|
||||
brew tap messense/macos-cross-toolchains
|
||||
brew install aarch64-unknown-linux-gnu
|
||||
|
||||
# Build
|
||||
CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
# Copy sang Uno Q
|
||||
scp target/aarch64-unknown-linux-gnu/release/zeroclaw arduino@<UNO_Q_IP>:~/
|
||||
ssh arduino@<UNO_Q_IP> "sudo mv ~/zeroclaw /usr/local/bin/"
|
||||
```
|
||||
|
||||
Nếu cross-compile thất bại, dùng Phương án A và build trực tiếp trên thiết bị.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cấu hình ZeroClaw
|
||||
|
||||
### 3.1 Chạy Onboard (hoặc tạo Config thủ công)
|
||||
|
||||
```bash
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
|
||||
# Cấu hình nhanh
|
||||
zeroclaw onboard --api-key YOUR_OPENROUTER_KEY --provider openrouter
|
||||
|
||||
# Hoặc tạo config thủ công
|
||||
mkdir -p ~/.zeroclaw/workspace
|
||||
nano ~/.zeroclaw/config.toml
|
||||
```
|
||||
|
||||
### 3.2 config.toml tối giản
|
||||
|
||||
```toml
|
||||
api_key = "YOUR_OPENROUTER_API_KEY"
|
||||
default_provider = "openrouter"
|
||||
default_model = "anthropic/claude-sonnet-4-6"
|
||||
|
||||
[peripherals]
|
||||
enabled = false
|
||||
# GPIO qua Bridge yêu cầu Phase 4
|
||||
|
||||
[channels_config.telegram]
|
||||
bot_token = "YOUR_TELEGRAM_BOT_TOKEN"
|
||||
allowed_users = ["*"]
|
||||
|
||||
[gateway]
|
||||
host = "127.0.0.1"
|
||||
port = 3000
|
||||
allow_public_bind = false
|
||||
|
||||
[agent]
|
||||
compact_context = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Chạy ZeroClaw Daemon
|
||||
|
||||
```bash
|
||||
ssh arduino@<UNO_Q_IP>
|
||||
|
||||
# Chạy daemon (Telegram polling hoạt động qua WiFi)
|
||||
zeroclaw daemon --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
**Tại bước này:** Telegram chat hoạt động. Gửi tin nhắn tới bot — ZeroClaw phản hồi. Chưa có GPIO.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: GPIO qua Bridge (ZeroClaw xử lý tự động)
|
||||
|
||||
ZeroClaw bao gồm Bridge app và setup command.
|
||||
|
||||
### 5.1 Triển khai Bridge App
|
||||
|
||||
**Từ Mac** (với repo zeroclaw):
|
||||
```bash
|
||||
zeroclaw peripheral setup-uno-q --host 192.168.0.48
|
||||
```
|
||||
|
||||
**Từ Uno Q** (đã SSH vào):
|
||||
```bash
|
||||
zeroclaw peripheral setup-uno-q
|
||||
```
|
||||
|
||||
Lệnh này copy Bridge app vào `~/ArduinoApps/uno-q-bridge` và khởi động nó.
|
||||
|
||||
### 5.2 Thêm vào config.toml
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "arduino-uno-q"
|
||||
transport = "bridge"
|
||||
```
|
||||
|
||||
### 5.3 Chạy ZeroClaw
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
Giờ khi bạn nhắn tin cho Telegram bot *"Turn on the LED"* hoặc *"Set pin 13 high"*, ZeroClaw dùng `gpio_write` qua Bridge.
|
||||
|
||||
---
|
||||
|
||||
## Tóm tắt: Các lệnh từ đầu đến cuối
|
||||
|
||||
| Bước | Lệnh |
|
||||
|------|------|
|
||||
| 1 | Cấu hình Uno Q trong App Lab (WiFi, SSH) |
|
||||
| 2 | `ssh arduino@<IP>` |
|
||||
| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` |
|
||||
| 4 | `sudo apt-get install -y pkg-config libssl-dev` |
|
||||
| 5 | `git clone https://github.com/zeroclaw-labs/zeroclaw.git && cd zeroclaw` |
|
||||
| 6 | `cargo build --release --no-default-features` |
|
||||
| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` |
|
||||
| 8 | Chỉnh sửa `~/.zeroclaw/config.toml` (thêm Telegram bot_token) |
|
||||
| 9 | `zeroclaw daemon --host 127.0.0.1 --port 3000` |
|
||||
| 10 | Nhắn tin cho Telegram bot — nó phản hồi |
|
||||
|
||||
---
|
||||
|
||||
## Xử lý sự cố
|
||||
|
||||
- **"command not found: zeroclaw"** — Dùng đường dẫn đầy đủ: `/usr/local/bin/zeroclaw` hoặc đảm bảo `~/.cargo/bin` nằm trong PATH.
|
||||
- **Telegram không phản hồi** — Kiểm tra bot_token, allowed_users, và Uno Q có kết nối internet (WiFi).
|
||||
- **Hết bộ nhớ** — Dùng `--no-default-features` để giảm kích thước binary; cân nhắc `compact_context = true`.
|
||||
- **Lệnh GPIO bị bỏ qua** — Đảm bảo Bridge app đang chạy (`zeroclaw peripheral setup-uno-q` triển khai và khởi động nó). Config phải có `board = "arduino-uno-q"` và `transport = "bridge"`.
|
||||
- **LLM provider (GLM/Zhipu)** — Dùng `default_provider = "glm"` hoặc `"zhipu"` với `GLM_API_KEY` trong env hoặc config. ZeroClaw dùng endpoint v4 chính xác.
|
||||
@@ -1,192 +0,0 @@
|
||||
# Audit logging
|
||||
|
||||
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
|
||||
>
|
||||
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
|
||||
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Vấn đề
|
||||
|
||||
ZeroClaw ghi log các hành động nhưng thiếu audit trail chống giả mạo cho:
|
||||
- Ai đã thực thi lệnh nào
|
||||
- Khi nào và từ channel nào
|
||||
- Những tài nguyên nào được truy cập
|
||||
- Chính sách bảo mật có bị kích hoạt không
|
||||
|
||||
---
|
||||
|
||||
## Định dạng audit log đề xuất
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-02-16T12:34:56Z",
|
||||
"event_id": "evt_1a2b3c4d",
|
||||
"event_type": "command_execution",
|
||||
"actor": {
|
||||
"channel": "telegram",
|
||||
"user_id": "123456789",
|
||||
"username": "@alice"
|
||||
},
|
||||
"action": {
|
||||
"command": "ls -la",
|
||||
"risk_level": "low",
|
||||
"approved": false,
|
||||
"allowed": true
|
||||
},
|
||||
"result": {
|
||||
"success": true,
|
||||
"exit_code": 0,
|
||||
"duration_ms": 15
|
||||
},
|
||||
"security": {
|
||||
"policy_violation": false,
|
||||
"rate_limit_remaining": 19
|
||||
},
|
||||
"signature": "SHA256:abc123..." // HMAC để chống giả mạo
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Triển khai
|
||||
|
||||
```rust
|
||||
// src/security/audit.rs
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuditEvent {
|
||||
pub timestamp: String,
|
||||
pub event_id: String,
|
||||
pub event_type: AuditEventType,
|
||||
pub actor: Actor,
|
||||
pub action: Action,
|
||||
pub result: ExecutionResult,
|
||||
pub security: SecurityContext,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AuditEventType {
|
||||
CommandExecution,
|
||||
FileAccess,
|
||||
ConfigurationChange,
|
||||
AuthSuccess,
|
||||
AuthFailure,
|
||||
PolicyViolation,
|
||||
}
|
||||
|
||||
pub struct AuditLogger {
|
||||
log_path: PathBuf,
|
||||
signing_key: Option<hmac::Hmac<sha2::Sha256>>,
|
||||
}
|
||||
|
||||
impl AuditLogger {
|
||||
pub fn log(&self, event: &AuditEvent) -> anyhow::Result<()> {
|
||||
let mut line = serde_json::to_string(event)?;
|
||||
|
||||
// Thêm chữ ký HMAC nếu key được cấu hình
|
||||
if let Some(ref key) = self.signing_key {
|
||||
let signature = compute_hmac(key, line.as_bytes());
|
||||
line.push_str(&format!("\n\"signature\": \"{}\"", signature));
|
||||
}
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.log_path)?;
|
||||
|
||||
writeln!(file, "{}", line)?;
|
||||
file.sync_all()?; // Flush cưỡng bức để đảm bảo độ bền
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn search(&self, filter: AuditFilter) -> Vec<AuditEvent> {
|
||||
// Tìm kiếm file log theo tiêu chí filter
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config schema
|
||||
|
||||
```toml
|
||||
[security.audit]
|
||||
enabled = true
|
||||
log_path = "~/.config/zeroclaw/audit.log"
|
||||
max_size_mb = 100
|
||||
rotate = "daily" # daily | weekly | size
|
||||
|
||||
# Chống giả mạo
|
||||
sign_events = true
|
||||
signing_key_path = "~/.config/zeroclaw/audit.key"
|
||||
|
||||
# Những gì cần log
|
||||
log_commands = true
|
||||
log_file_access = true
|
||||
log_auth_events = true
|
||||
log_policy_violations = true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI truy vấn audit
|
||||
|
||||
```bash
|
||||
# Hiển thị tất cả lệnh được thực thi bởi @alice
|
||||
zeroclaw audit --user @alice
|
||||
|
||||
# Hiển thị tất cả lệnh rủi ro cao
|
||||
zeroclaw audit --risk high
|
||||
|
||||
# Hiển thị vi phạm trong 24 giờ qua
|
||||
zeroclaw audit --since 24h --violations-only
|
||||
|
||||
# Xuất sang JSON để phân tích
|
||||
zeroclaw audit --format json --output audit.json
|
||||
|
||||
# Xác minh tính toàn vẹn của log
|
||||
zeroclaw audit --verify-signatures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Xoay vòng log
|
||||
|
||||
```rust
|
||||
pub fn rotate_audit_log(log_path: &PathBuf, max_size: u64) -> anyhow::Result<()> {
|
||||
let metadata = std::fs::metadata(log_path)?;
|
||||
if metadata.len() < max_size {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Xoay vòng: audit.log -> audit.log.1 -> audit.log.2 -> ...
|
||||
let stem = log_path.file_stem().unwrap_or_default();
|
||||
let extension = log_path.extension().and_then(|s| s.to_str()).unwrap_or("log");
|
||||
|
||||
for i in (1..10).rev() {
|
||||
let old_name = format!("{}.{}.{}", stem, i, extension);
|
||||
let new_name = format!("{}.{}.{}", stem, i + 1, extension);
|
||||
let _ = std::fs::rename(old_name, new_name);
|
||||
}
|
||||
|
||||
let rotated = format!("{}.1.{}", stem, extension);
|
||||
std::fs::rename(log_path, &rotated)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thứ tự triển khai
|
||||
|
||||
| Giai đoạn | Tính năng | Công sức | Giá trị bảo mật |
|
||||
|-------|---------|--------|----------------|
|
||||
| **P0** | Ghi log sự kiện cơ bản | Thấp | Trung bình |
|
||||
| **P1** | Query CLI | Trung bình | Trung bình |
|
||||
| **P2** | Ký HMAC | Trung bình | Cao |
|
||||
| **P3** | Xoay vòng log + lưu trữ | Thấp | Trung bình |
|
||||
@@ -1,424 +0,0 @@
|
||||
# Tài liệu tham khảo Channels
|
||||
|
||||
Tài liệu này là nguồn tham khảo chính thức về cấu hình channel trong ZeroClaw.
|
||||
|
||||
Với các phòng Matrix được mã hóa, xem hướng dẫn chuyên biệt:
|
||||
- [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md)
|
||||
|
||||
## Truy cập nhanh
|
||||
|
||||
- Cần tham khảo config đầy đủ theo từng channel: xem mục `## 4. Ví dụ cấu hình theo từng channel`.
|
||||
- Cần chẩn đoán khi không nhận được phản hồi: xem mục `## 6. Danh sách kiểm tra xử lý sự cố`.
|
||||
- Cần hỗ trợ phòng Matrix được mã hóa: dùng [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md).
|
||||
- Cần thông tin triển khai/mạng (polling vs webhook): dùng [Network Deployment](network-deployment.md).
|
||||
|
||||
## FAQ: Cấu hình Matrix thành công nhưng không có phản hồi
|
||||
|
||||
Đây là triệu chứng phổ biến nhất (cùng loại với issue #499). Kiểm tra theo thứ tự sau:
|
||||
|
||||
1. **Allowlist không khớp**: `allowed_users` không bao gồm người gửi (hoặc để trống).
|
||||
2. **Room đích sai**: bot chưa tham gia room được cấu hình `room_id` / alias.
|
||||
3. **Token/tài khoản không khớp**: token hợp lệ nhưng thuộc tài khoản Matrix khác.
|
||||
4. **Thiếu E2EE device identity**: `whoami` không trả về `device_id` và config không cung cấp giá trị này.
|
||||
5. **Thiếu key sharing/trust**: các khóa room chưa được chia sẻ cho thiết bị bot, nên không thể giải mã sự kiện mã hóa.
|
||||
6. **Trạng thái runtime cũ**: config đã thay đổi nhưng `zeroclaw daemon` chưa được khởi động lại.
|
||||
|
||||
---
|
||||
|
||||
## 1. Namespace cấu hình
|
||||
|
||||
Tất cả cài đặt channel nằm trong `channels_config` trong `~/.zeroclaw/config.toml`.
|
||||
|
||||
```toml
|
||||
[channels_config]
|
||||
cli = true
|
||||
```
|
||||
|
||||
Mỗi channel được bật bằng cách tạo sub-table tương ứng (ví dụ: `[channels_config.telegram]`).
|
||||
|
||||
## Chuyển đổi model runtime trong chat (Telegram / Discord)
|
||||
|
||||
Khi chạy `zeroclaw channel start` (hoặc chế độ daemon), Telegram và Discord hỗ trợ chuyển đổi runtime theo phạm vi người gửi:
|
||||
|
||||
- `/models` — hiển thị các provider hiện có và lựa chọn hiện tại
|
||||
- `/models <provider>` — chuyển provider cho phiên người gửi hiện tại
|
||||
- `/model` — hiển thị model hiện tại và các model ID đã cache (nếu có)
|
||||
- `/model <model-id>` — chuyển model cho phiên người gửi hiện tại
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Việc chuyển đổi chỉ xóa lịch sử hội thoại trong bộ nhớ của người gửi đó, tránh ô nhiễm ngữ cảnh giữa các model.
|
||||
- Xem trước bộ nhớ cache model từ `zeroclaw models refresh --provider <ID>`.
|
||||
- Đây là lệnh chat runtime, không phải lệnh con CLI.
|
||||
|
||||
## Giao thức marker hình ảnh đầu vào
|
||||
|
||||
ZeroClaw hỗ trợ đầu vào multimodal qua các marker nội tuyến trong tin nhắn:
|
||||
|
||||
- Cú pháp: ``[IMAGE:<source>]``
|
||||
- `<source>` có thể là:
|
||||
- Đường dẫn file cục bộ
|
||||
- Data URI (`data:image/...;base64,...`)
|
||||
- URL từ xa chỉ khi `[multimodal].allow_remote_fetch = true`
|
||||
|
||||
Lưu ý vận hành:
|
||||
|
||||
- Marker được phân tích trong các tin nhắn người dùng trước khi gọi provider.
|
||||
- Capability của provider được kiểm tra tại runtime: nếu provider không hỗ trợ vision, request thất bại với lỗi capability có cấu trúc (`capability=vision`).
|
||||
- Các phần `media` của Linq webhook có MIME type `image/*` được tự động chuyển đổi sang định dạng marker này.
|
||||
|
||||
## Channel Matrix
|
||||
|
||||
### Tùy chọn Build Feature (`channel-matrix`)
|
||||
|
||||
Hỗ trợ Matrix được kiểm soát tại thời điểm biên dịch bằng Cargo feature `channel-matrix`.
|
||||
|
||||
- Các bản build mặc định đã bao gồm hỗ trợ Matrix (`default = ["hardware", "channel-matrix"]`).
|
||||
- Để lặp lại nhanh hơn khi không cần Matrix:
|
||||
|
||||
```bash
|
||||
cargo check --no-default-features --features hardware
|
||||
```
|
||||
|
||||
- Để bật tường minh hỗ trợ Matrix trong feature set tùy chỉnh:
|
||||
|
||||
```bash
|
||||
cargo check --no-default-features --features hardware,channel-matrix
|
||||
```
|
||||
|
||||
Nếu `[channels_config.matrix]` có mặt nhưng binary được build mà không có `channel-matrix`, các lệnh `zeroclaw channel list`, `zeroclaw channel doctor`, và `zeroclaw channel start` sẽ ghi log rằng Matrix bị bỏ qua có chủ ý trong bản build này.
|
||||
|
||||
---
|
||||
|
||||
## 2. Chế độ phân phối tóm tắt
|
||||
|
||||
| Channel | Chế độ nhận | Cần cổng inbound công khai? |
|
||||
|---|---|---|
|
||||
| CLI | local stdin/stdout | Không |
|
||||
| Telegram | polling | Không |
|
||||
| Discord | gateway/websocket | Không |
|
||||
| Slack | events API | Không (luồng token-based) |
|
||||
| Mattermost | polling | Không |
|
||||
| Matrix | sync API (hỗ trợ E2EE) | Không |
|
||||
| Signal | signal-cli HTTP bridge | Không (endpoint bridge cục bộ) |
|
||||
| WhatsApp | webhook (Cloud API) hoặc websocket (Web mode) | Cloud API: Có (HTTPS callback công khai), Web mode: Không |
|
||||
| Webhook | gateway endpoint (`/webhook`) | Thường là có |
|
||||
| Email | IMAP polling + SMTP send | Không |
|
||||
| IRC | IRC socket | Không |
|
||||
| Lark/Feishu | websocket (mặc định) hoặc webhook | Chỉ ở chế độ Webhook |
|
||||
| DingTalk | stream mode | Không |
|
||||
| QQ | bot gateway | Không |
|
||||
| iMessage | tích hợp cục bộ | Không |
|
||||
|
||||
---
|
||||
|
||||
## 3. Ngữ nghĩa allowlist
|
||||
|
||||
Với các channel có allowlist người gửi:
|
||||
|
||||
- Allowlist trống: từ chối tất cả tin nhắn đầu vào.
|
||||
- `"*"`: cho phép tất cả người gửi (chỉ dùng để xác minh tạm thời).
|
||||
- Danh sách tường minh: chỉ cho phép những người gửi được liệt kê.
|
||||
|
||||
Tên trường khác nhau theo channel:
|
||||
|
||||
- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/DingTalk/QQ)
|
||||
- `allowed_from` (Signal)
|
||||
- `allowed_numbers` (WhatsApp)
|
||||
- `allowed_senders` (Email)
|
||||
- `allowed_contacts` (iMessage)
|
||||
|
||||
---
|
||||
|
||||
## 4. Ví dụ cấu hình theo từng channel
|
||||
|
||||
### 4.1 Telegram
|
||||
|
||||
```toml
|
||||
[channels_config.telegram]
|
||||
bot_token = "123456:telegram-token"
|
||||
allowed_users = ["*"]
|
||||
stream_mode = "off" # tùy chọn: off | partial
|
||||
draft_update_interval_ms = 1000 # tùy chọn: giới hạn tần suất chỉnh sửa khi streaming một phần
|
||||
mention_only = false # tùy chọn: yêu cầu @mention trong nhóm
|
||||
interrupt_on_new_message = false # tùy chọn: hủy yêu cầu đang xử lý cùng người gửi cùng chat
|
||||
```
|
||||
|
||||
Lưu ý về Telegram:
|
||||
|
||||
- `interrupt_on_new_message = true` giữ lại các lượt người dùng bị gián đoạn trong lịch sử hội thoại, sau đó khởi động lại việc tạo nội dung với tin nhắn mới nhất.
|
||||
- Phạm vi gián đoạn rất chặt chẽ: cùng người gửi trong cùng chat. Tin nhắn từ các chat khác nhau được xử lý độc lập.
|
||||
|
||||
### 4.2 Discord
|
||||
|
||||
```toml
|
||||
[channels_config.discord]
|
||||
bot_token = "discord-bot-token"
|
||||
guild_id = "123456789012345678" # tùy chọn
|
||||
allowed_users = ["*"]
|
||||
listen_to_bots = false
|
||||
mention_only = false
|
||||
```
|
||||
|
||||
### 4.3 Slack
|
||||
|
||||
```toml
|
||||
[channels_config.slack]
|
||||
bot_token = "xoxb-..."
|
||||
app_token = "xapp-..." # tùy chọn
|
||||
channel_id = "C1234567890" # tùy chọn
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
### 4.4 Mattermost
|
||||
|
||||
```toml
|
||||
[channels_config.mattermost]
|
||||
url = "https://mm.example.com"
|
||||
bot_token = "mattermost-token"
|
||||
channel_id = "channel-id" # bắt buộc để lắng nghe
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
### 4.5 Matrix
|
||||
|
||||
```toml
|
||||
[channels_config.matrix]
|
||||
homeserver = "https://matrix.example.com"
|
||||
access_token = "syt_..."
|
||||
user_id = "@zeroclaw:matrix.example.com" # tùy chọn, khuyến nghị cho E2EE
|
||||
device_id = "DEVICEID123" # tùy chọn, khuyến nghị cho E2EE
|
||||
room_id = "!room:matrix.example.com" # hoặc room alias (#ops:matrix.example.com)
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
Xem [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md) để xử lý sự cố phòng mã hóa.
|
||||
|
||||
### 4.6 Signal
|
||||
|
||||
```toml
|
||||
[channels_config.signal]
|
||||
http_url = "http://127.0.0.1:8686"
|
||||
account = "+1234567890"
|
||||
group_id = "dm" # tùy chọn: "dm" / group id / bỏ qua
|
||||
allowed_from = ["*"]
|
||||
ignore_attachments = false
|
||||
ignore_stories = true
|
||||
```
|
||||
|
||||
### 4.7 WhatsApp
|
||||
|
||||
ZeroClaw hỗ trợ hai backend WhatsApp:
|
||||
|
||||
- **Chế độ Cloud API** (`phone_number_id` + `access_token` + `verify_token`)
|
||||
- **Chế độ WhatsApp Web** (`session_path`, yêu cầu build flag `--features whatsapp-web`)
|
||||
|
||||
Chế độ Cloud API:
|
||||
|
||||
```toml
|
||||
[channels_config.whatsapp]
|
||||
access_token = "EAAB..."
|
||||
phone_number_id = "123456789012345"
|
||||
verify_token = "your-verify-token"
|
||||
app_secret = "your-app-secret" # tùy chọn nhưng được khuyến nghị
|
||||
allowed_numbers = ["*"]
|
||||
```
|
||||
|
||||
Chế độ WhatsApp Web:
|
||||
|
||||
```toml
|
||||
[channels_config.whatsapp]
|
||||
session_path = "~/.zeroclaw/state/whatsapp-web/session.db"
|
||||
pair_phone = "15551234567" # tùy chọn; bỏ qua để dùng QR flow
|
||||
pair_code = "" # tùy chọn pair code tùy chỉnh
|
||||
allowed_numbers = ["*"]
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Build với `cargo build --features whatsapp-web` (hoặc lệnh run tương đương).
|
||||
- Giữ `session_path` trên bộ nhớ lưu trữ bền vững để tránh phải liên kết lại sau khi khởi động lại.
|
||||
- Định tuyến trả lời sử dụng JID của chat nguồn, vì vậy cả trả lời trực tiếp và nhóm đều hoạt động đúng.
|
||||
|
||||
### 4.8 Cấu hình Webhook Channel (Gateway)
|
||||
|
||||
`channels_config.webhook` bật hành vi gateway đặc thù cho webhook.
|
||||
|
||||
```toml
|
||||
[channels_config.webhook]
|
||||
port = 8080
|
||||
secret = "optional-shared-secret"
|
||||
```
|
||||
|
||||
Chạy với gateway/daemon và xác minh `/health`.
|
||||
|
||||
### 4.9 Email
|
||||
|
||||
```toml
|
||||
[channels_config.email]
|
||||
imap_host = "imap.example.com"
|
||||
imap_port = 993
|
||||
imap_folder = "INBOX"
|
||||
smtp_host = "smtp.example.com"
|
||||
smtp_port = 465
|
||||
smtp_tls = true
|
||||
username = "bot@example.com"
|
||||
password = "email-password"
|
||||
from_address = "bot@example.com"
|
||||
poll_interval_secs = 60
|
||||
allowed_senders = ["*"]
|
||||
```
|
||||
|
||||
### 4.10 IRC
|
||||
|
||||
```toml
|
||||
[channels_config.irc]
|
||||
server = "irc.libera.chat"
|
||||
port = 6697
|
||||
nickname = "zeroclaw-bot"
|
||||
username = "zeroclaw" # tùy chọn
|
||||
channels = ["#zeroclaw"]
|
||||
allowed_users = ["*"]
|
||||
server_password = "" # tùy chọn
|
||||
nickserv_password = "" # tùy chọn
|
||||
sasl_password = "" # tùy chọn
|
||||
verify_tls = true
|
||||
```
|
||||
|
||||
### 4.11 Lark / Feishu
|
||||
|
||||
```toml
|
||||
[channels_config.lark]
|
||||
app_id = "cli_xxx"
|
||||
app_secret = "xxx"
|
||||
encrypt_key = "" # tùy chọn
|
||||
verification_token = "" # tùy chọn
|
||||
allowed_users = ["*"]
|
||||
use_feishu = false
|
||||
receive_mode = "websocket" # hoặc "webhook"
|
||||
port = 8081 # bắt buộc ở chế độ webhook
|
||||
```
|
||||
|
||||
Hỗ trợ onboarding hướng dẫn:
|
||||
|
||||
```bash
|
||||
zeroclaw onboard
|
||||
```
|
||||
|
||||
Trình hướng dẫn bao gồm bước **Lark/Feishu** chuyên biệt với:
|
||||
|
||||
- Chọn khu vực (`Feishu (CN)` hoặc `Lark (International)`)
|
||||
- Xác minh thông tin xác thực với endpoint auth của Open Platform chính thức
|
||||
- Chọn chế độ nhận (`websocket` hoặc `webhook`)
|
||||
- Tùy chọn nhập verification token webhook (khuyến nghị để tăng cường kiểm tra tính xác thực của callback)
|
||||
|
||||
Hành vi token runtime:
|
||||
|
||||
- `tenant_access_token` được cache với thời hạn làm mới dựa trên `expire`/`expires_in` từ phản hồi xác thực.
|
||||
- Các yêu cầu gửi tự động thử lại một lần sau khi token bị vô hiệu hóa khi Feishu/Lark trả về HTTP `401` hoặc mã lỗi nghiệp vụ `99991663` (`Invalid access token`).
|
||||
- Nếu lần thử lại vẫn trả về phản hồi token không hợp lệ, lời gọi gửi sẽ thất bại với trạng thái/nội dung upstream để dễ xử lý sự cố hơn.
|
||||
|
||||
### 4.12 DingTalk
|
||||
|
||||
```toml
|
||||
[channels_config.dingtalk]
|
||||
client_id = "ding-app-key"
|
||||
client_secret = "ding-app-secret"
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
### 4.13 QQ
|
||||
|
||||
```toml
|
||||
[channels_config.qq]
|
||||
app_id = "qq-app-id"
|
||||
app_secret = "qq-app-secret"
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
### 4.14 iMessage
|
||||
|
||||
```toml
|
||||
[channels_config.imessage]
|
||||
allowed_contacts = ["*"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Quy trình xác thực
|
||||
|
||||
1. Cấu hình một channel với allowlist rộng (`"*"`) để xác minh ban đầu.
|
||||
2. Chạy:
|
||||
|
||||
```bash
|
||||
zeroclaw onboard --channels-only
|
||||
zeroclaw daemon
|
||||
```
|
||||
|
||||
1. Gửi tin nhắn từ người gửi dự kiến.
|
||||
2. Xác nhận nhận được phản hồi.
|
||||
3. Siết chặt allowlist từ `"*"` thành các ID cụ thể.
|
||||
|
||||
---
|
||||
|
||||
## 6. Danh sách kiểm tra xử lý sự cố
|
||||
|
||||
Nếu channel có vẻ đã kết nối nhưng không phản hồi:
|
||||
|
||||
1. Xác nhận danh tính người gửi được cho phép bởi trường allowlist đúng.
|
||||
2. Xác nhận tài khoản bot đã là thành viên/có quyền trong room/channel đích.
|
||||
3. Xác nhận token/secret hợp lệ (và chưa hết hạn/bị thu hồi).
|
||||
4. Xác nhận giả định về chế độ truyền tải:
|
||||
- Các channel polling/websocket không cần HTTP inbound công khai
|
||||
- Các channel webhook cần HTTPS callback có thể truy cập được
|
||||
5. Khởi động lại `zeroclaw daemon` sau khi thay đổi config.
|
||||
|
||||
Đặc biệt với các phòng Matrix mã hóa, dùng:
|
||||
- [Hướng dẫn Matrix E2EE](matrix-e2ee-guide.md)
|
||||
|
||||
---
|
||||
|
||||
## 7. Phụ lục vận hành: bảng từ khóa log
|
||||
|
||||
Dùng phụ lục này để phân loại sự cố nhanh. Khớp từ khóa log trước, sau đó thực hiện các bước xử lý sự cố ở trên.
|
||||
|
||||
### 7.1 Lệnh capture được khuyến nghị
|
||||
|
||||
```bash
|
||||
RUST_LOG=info zeroclaw daemon 2>&1 | tee /tmp/zeroclaw.log
|
||||
```
|
||||
|
||||
Sau đó lọc các sự kiện channel/gateway:
|
||||
|
||||
```bash
|
||||
rg -n "Matrix|Telegram|Discord|Slack|Mattermost|Signal|WhatsApp|Email|IRC|Lark|DingTalk|QQ|iMessage|Webhook|Channel" /tmp/zeroclaw.log
|
||||
```
|
||||
|
||||
### 7.2 Bảng từ khóa
|
||||
|
||||
| Thành phần | Tín hiệu khởi động / hoạt động bình thường | Tín hiệu ủy quyền / chính sách | Tín hiệu truyền tải / lỗi |
|
||||
|---|---|---|---|
|
||||
| Telegram | `Telegram channel listening for messages...` | `Telegram: ignoring message from unauthorized user:` | `Telegram poll error:` / `Telegram parse error:` / `Telegram polling conflict (409):` |
|
||||
| Discord | `Discord: connected and identified` | `Discord: ignoring message from unauthorized user:` | `Discord: received Reconnect (op 7)` / `Discord: received Invalid Session (op 9)` |
|
||||
| Slack | `Slack channel listening on #` | `Slack: ignoring message from unauthorized user:` | `Slack poll error:` / `Slack parse error:` |
|
||||
| Mattermost | `Mattermost channel listening on` | `Mattermost: ignoring message from unauthorized user:` | `Mattermost poll error:` / `Mattermost parse error:` |
|
||||
| Matrix | `Matrix channel listening on room` / `Matrix room ... is encrypted; E2EE decryption is enabled via matrix-sdk.` | `Matrix whoami failed; falling back to configured session hints for E2EE session restore:` / `Matrix whoami failed while resolving listener user_id; using configured user_id hint:` | `Matrix sync error: ... retrying...` |
|
||||
| Signal | `Signal channel listening via SSE on` | (kiểm tra allowlist được thực thi bởi `allowed_from`) | `Signal SSE returned ...` / `Signal SSE connect error:` |
|
||||
| WhatsApp (channel) | `WhatsApp channel active (webhook mode).` / `WhatsApp Web connected successfully` | `WhatsApp: ignoring message from unauthorized number:` / `WhatsApp Web: message from ... not in allowed list` | `WhatsApp send failed:` / `WhatsApp Web stream error:` |
|
||||
| Webhook / WhatsApp (gateway) | `WhatsApp webhook verified successfully` | `Webhook: rejected — not paired / invalid bearer token` / `Webhook: rejected request — invalid or missing X-Webhook-Secret` / `WhatsApp webhook verification failed — token mismatch` | `Webhook JSON parse error:` |
|
||||
| Email | `Email polling every ...` / `Email sent to ...` | `Blocked email from ...` | `Email poll failed:` / `Email poll task panicked:` |
|
||||
| IRC | `IRC channel connecting to ...` / `IRC registered as ...` | (kiểm tra allowlist được thực thi bởi `allowed_users`) | `IRC SASL authentication failed (...)` / `IRC server does not support SASL...` / `IRC nickname ... is in use, trying ...` |
|
||||
| Lark / Feishu | `Lark: WS connected` / `Lark event callback server listening on` | `Lark WS: ignoring ... (not in allowed_users)` / `Lark: ignoring message from unauthorized user:` | `Lark: ping failed, reconnecting` / `Lark: heartbeat timeout, reconnecting` / `Lark: WS read error:` |
|
||||
| DingTalk | `DingTalk: connected and listening for messages...` | `DingTalk: ignoring message from unauthorized user:` | `DingTalk WebSocket error:` / `DingTalk: message channel closed` |
|
||||
| QQ | `QQ: connected and identified` | `QQ: ignoring C2C message from unauthorized user:` / `QQ: ignoring group message from unauthorized user:` | `QQ: received Reconnect (op 7)` / `QQ: received Invalid Session (op 9)` / `QQ: message channel closed` |
|
||||
| iMessage | `iMessage channel listening (AppleScript bridge)...` | (allowlist liên hệ được thực thi bởi `allowed_contacts`) | `iMessage poll error:` |
|
||||
|
||||
### 7.3 Từ khóa của runtime supervisor
|
||||
|
||||
Nếu một channel task cụ thể bị crash hoặc thoát, channel supervisor trong `channels/mod.rs` phát ra:
|
||||
|
||||
- `Channel <name> exited unexpectedly; restarting`
|
||||
- `Channel <name> error: ...; restarting`
|
||||
- `Channel message worker crashed:`
|
||||
|
||||
Các thông báo này xác nhận cơ chế tự restart đang hoạt động. Kiểm tra log trước đó để tìm nguyên nhân gốc rễ.
|
||||
@@ -1,125 +0,0 @@
|
||||
# Bản đồ CI Workflow
|
||||
|
||||
Tài liệu này giải thích từng GitHub workflow làm gì, khi nào chạy và liệu nó có nên chặn merge hay không.
|
||||
|
||||
Để biết hành vi phân phối theo từng sự kiện qua PR, merge, push và release, xem [`.github/workflows/master-branch-flow.md`](../../../.github/workflows/master-branch-flow.md).
|
||||
|
||||
## Chặn merge và Tùy chọn
|
||||
|
||||
Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. Các kiểm tra tùy chọn hữu ích cho tự động hóa và bảo trì, nhưng không nên chặn phát triển bình thường.
|
||||
|
||||
### Chặn merge
|
||||
|
||||
- `.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,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`)
|
||||
- Mục đích: lint các file GitHub workflow (`actionlint`, kiểm tra tab)
|
||||
- Khuyến nghị cho các PR thay đổi workflow
|
||||
- `.github/workflows/pr-intake-checks.yml` (`PR Intake Checks`)
|
||||
- Mục đích: kiểm tra PR an toàn trước CI (độ đầy đủ template, tab/trailing-whitespace/conflict marker trên dòng thêm) với comment sticky phản hồi ngay lập tức
|
||||
|
||||
### Quan trọng nhưng không chặn
|
||||
|
||||
- `.github/workflows/pub-docker-img.yml` (`Docker`)
|
||||
- Mục đích: kiểm tra Docker smoke trên PR lên `master` và publish image khi push tag (`v*`) only
|
||||
- `.github/workflows/sec-audit.yml` (`Security Audit`)
|
||||
- Mục đích: advisory phụ thuộc (`rustsec/audit-check`, SHA được pin) và kiểm tra chính sách/giấy phép (`cargo deny`)
|
||||
- `.github/workflows/sec-codeql.yml` (`CodeQL Analysis`)
|
||||
- Mục đích: phân tích tĩnh theo lịch/thủ công để phát hiện vấn đề bảo mật
|
||||
- `.github/workflows/sec-vorpal-reviewdog.yml` (`Sec Vorpal Reviewdog`)
|
||||
- Mục đích: quét phản hồi secure-coding thủ công cho các file non-Rust được hỗ trợ (`.py`, `.js`, `.jsx`, `.ts`, `.tsx`) sử dụng annotation reviewdog
|
||||
- Kiểm soát nhiễu: loại trừ các đường dẫn test/fixture phổ biến và pattern file test theo mặc định (`include_tests=false`)
|
||||
- `.github/workflows/pub-release.yml` (`Release`)
|
||||
- Mục đích: build release artifact ở chế độ xác minh (thủ công/theo lịch) và publish GitHub release khi push tag hoặc chế độ publish thủ công
|
||||
- `.github/workflows/pub-homebrew-core.yml` (`Pub Homebrew Core`)
|
||||
- Mục đích: luồng PR bump formula Homebrew core thủ công, do bot sở hữu cho các tagged release
|
||||
- Bảo vệ: release tag phải khớp version `Cargo.toml`
|
||||
- `.github/workflows/pr-label-policy-check.yml` (`Label Policy Sanity`)
|
||||
- Mục đích: xác thực chính sách bậc contributor dùng chung trong `.github/label-policy.json` và đảm bảo các label workflow sử dụng chính sách đó
|
||||
- `.github/workflows/test-rust-build.yml` (`Rust Reusable Job`)
|
||||
- Mục đích: Rust setup/cache có thể tái sử dụng + trình chạy lệnh cho các workflow-call consumer
|
||||
|
||||
### Tự động hóa repository tùy chọn
|
||||
|
||||
- `.github/workflows/pr-labeler.yml` (`PR Labeler`)
|
||||
- Mục đích: nhãn phạm vi/đường dẫn + nhãn kích thước/rủi ro + nhãn module chi tiết (`<module>: <component>`)
|
||||
- Hành vi bổ sung: mô tả nhãn được quản lý tự động như tooltip khi di chuột để giải thích từng quy tắc phán đoán tự động
|
||||
- Hành vi bổ sung: từ khóa liên quan đến provider trong các thay đổi provider/config/onboard/integration được thăng cấp lên nhãn `provider:*` (ví dụ `provider:kimi`, `provider:deepseek`)
|
||||
- Hành vi bổ sung: loại bỏ trùng lặp phân cấp chỉ giữ nhãn phạm vi cụ thể nhất (ví dụ `tool:composio` triệt tiêu `tool:core` và `tool`)
|
||||
- Hành vi bổ sung: namespace module được nén gọn — một module cụ thể giữ `prefix:component`; nhiều module cụ thể thu gọn thành chỉ `prefix`
|
||||
- Hành vi bổ sung: áp dụng bậc contributor trên PR theo số PR đã merge (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50)
|
||||
- Hành vi bổ sung: bộ nhãn cuối cùng được sắp xếp theo ưu tiên (`risk:*` đầu tiên, sau đó `size:*`, rồi bậc contributor, cuối là nhãn module/đường dẫn)
|
||||
- Hành vi bổ sung: màu nhãn được quản lý theo thứ tự hiển thị để tạo gradient trái-phải mượt mà khi có nhiều nhãn
|
||||
- Quản trị thủ công: hỗ trợ `workflow_dispatch` với `mode=audit|repair` để kiểm tra/sửa metadata nhãn được quản lý drift trên toàn repository
|
||||
- Hành vi bổ sung: nhãn rủi ro + kích thước được tự sửa khi chỉnh sửa nhãn PR thủ công (sự kiện `labeled`/`unlabeled`); áp dụng `risk: manual` khi maintainer cố ý ghi đè lựa chọn rủi ro tự động
|
||||
- Đường dẫn heuristic rủi ro cao: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**`
|
||||
- Bảo vệ: maintainer có thể áp dụng `risk: manual` để đóng băng tính toán lại rủi ro tự động
|
||||
- `.github/workflows/pr-auto-response.yml` (`PR Auto Responder`)
|
||||
- Mục đích: giới thiệu contributor lần đầu + phân tuyến dựa trên nhãn (`r:support`, `r:needs-repro`, v.v.)
|
||||
- Hành vi bổ sung: áp dụng bậc contributor trên issue theo số PR đã merge (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), khớp chính xác ngưỡng bậc PR
|
||||
- Hành vi bổ sung: nhãn bậc contributor được coi là do tự động hóa quản lý (thêm/xóa thủ công trên PR/issue bị tự sửa)
|
||||
- Bảo vệ: các luồng đóng dựa trên nhãn chỉ dành cho issue; PR không bao giờ bị tự đóng bởi nhãn route
|
||||
- `.github/workflows/pr-check-stale.yml` (`Stale`)
|
||||
- Mục đích: tự động hóa vòng đời issue/PR stale
|
||||
- `.github/dependabot.yml` (`Dependabot`)
|
||||
- Mục đích: PR cập nhật phụ thuộc được nhóm, giới hạn tốc độ (Cargo + GitHub Actions)
|
||||
- `.github/workflows/pr-check-status.yml` (`PR Hygiene`)
|
||||
- Mục đích: nhắc nhở các PR stale-nhưng-còn-hoạt-động để rebase/re-run các kiểm tra bắt buộc trước khi hàng đợi bị đói
|
||||
|
||||
## Bản đồ Trigger
|
||||
|
||||
- `CI`: push lên `master`, PR lên `master`
|
||||
- `Docker`: push tag (`v*`) để publish, PR lên `master` tương ứng để smoke build, dispatch thủ công chỉ smoke
|
||||
- `Release`: push tag (`v*`), lịch hàng tuần (chỉ xác minh), dispatch thủ công (xác minh hoặc publish)
|
||||
- `Pub Homebrew Core`: dispatch thủ công only
|
||||
- `Security Audit`: push lên `master`, PR lên `master`, lịch hàng tuần
|
||||
- `Sec Vorpal Reviewdog`: dispatch thủ công only
|
||||
- `Workflow Sanity`: PR/push khi `.github/workflows/**`, `.github/*.yml` hoặc `.github/*.yaml` thay đổi
|
||||
- `PR Intake Checks`: `pull_request_target` khi opened/reopened/synchronize/edited/ready_for_review
|
||||
- `Label Policy Sanity`: PR/push khi `.github/label-policy.json`, `.github/workflows/pr-labeler.yml` hoặc `.github/workflows/pr-auto-response.yml` thay đổi
|
||||
- `PR Labeler`: sự kiện vòng đời `pull_request_target`
|
||||
- `PR Auto Responder`: issue opened/labeled, `pull_request_target` opened/labeled
|
||||
- `Stale PR Check`: lịch hàng ngày, dispatch thủ công
|
||||
- `Dependabot`: tất cả PR cập nhật nhắm vào `master`
|
||||
- `PR Hygiene`: lịch mỗi 12 giờ, dispatch thủ công
|
||||
|
||||
## Hướng dẫn triage nhanh
|
||||
|
||||
1. `CI Required Gate` thất bại: bắt đầu với `.github/workflows/ci-run.yml`.
|
||||
2. Docker thất bại trên PR: kiểm tra job `pr-smoke` trong `.github/workflows/pub-docker-img.yml`.
|
||||
3. Release thất bại (tag/thủ công/theo lịch): kiểm tra `.github/workflows/pub-release.yml` và kết quả job `prepare`.
|
||||
4. Lỗi publish formula Homebrew: kiểm tra output tóm tắt `.github/workflows/pub-homebrew-core.yml` và biến bot token/fork.
|
||||
5. Security thất bại: kiểm tra `.github/workflows/sec-audit.yml` và `deny.toml`.
|
||||
6. Lỗi cú pháp/lint workflow: kiểm tra `.github/workflows/workflow-sanity.yml`.
|
||||
7. PR intake thất bại: kiểm tra comment sticky `.github/workflows/pr-intake-checks.yml` và run log.
|
||||
8. Lỗi parity chính sách nhãn: kiểm tra `.github/workflows/pr-label-policy-check.yml`.
|
||||
9. Lỗi tài liệu trong CI: kiểm tra log job `docs-quality` trong `.github/workflows/ci-run.yml`.
|
||||
10. Lỗi strict delta lint trong CI: kiểm tra log job `lint-strict-delta` và so sánh với phạm vi diff `BASE_SHA`.
|
||||
|
||||
## Quy tắc bảo trì
|
||||
|
||||
- Giữ các kiểm tra chặn merge mang tính quyết định và tái tạo được (`--locked` khi áp dụng được).
|
||||
- Tuân theo `docs/release-process.md` để kiểm tra trước khi publish và kỷ luật tag.
|
||||
- Giữ chính sách chất lượng Rust chặn merge nhất quán giữa `.github/workflows/ci-run.yml`, `dev/ci.sh` và `.githooks/pre-push` (`./scripts/ci/rust_quality_gate.sh` + `./scripts/ci/rust_strict_delta_gate.sh`).
|
||||
- Dùng `./scripts/ci/rust_strict_delta_gate.sh` (hoặc `./dev/ci.sh lint-delta`) làm merge gate nghiêm ngặt gia tăng cho các dòng Rust thay đổi.
|
||||
- Chạy kiểm tra lint nghiêm ngặt đầy đủ thường xuyên qua `./scripts/ci/rust_quality_gate.sh --strict` (ví dụ qua `./dev/ci.sh lint-strict`) và theo dõi việc dọn dẹp trong các PR tập trung.
|
||||
- Giữ gating markdown tài liệu theo gia tăng qua `./scripts/ci/docs_quality_gate.sh` (chặn vấn đề dòng thay đổi, báo cáo vấn đề baseline riêng).
|
||||
- Giữ gating link tài liệu theo gia tăng qua `./scripts/ci/collect_changed_links.py` + lychee (chỉ kiểm tra link mới thêm trên dòng thay đổi).
|
||||
- Ưu tiên quyền workflow tường minh (least privilege).
|
||||
- Giữ chính sách nguồn Actions hạn chế theo allowlist đã được phê duyệt (xem `docs/actions-source-policy.md`).
|
||||
- Sử dụng bộ lọc đường dẫn cho các workflow tốn kém khi thực tế.
|
||||
- Giữ kiểm tra chất lượng tài liệu ít nhiễu (markdown gia tăng + kiểm tra link mới thêm gia tăng).
|
||||
- Giữ khối lượng cập nhật phụ thuộc được kiểm soát (nhóm + giới hạn PR).
|
||||
- Tránh kết hợp tự động hóa giới thiệu/cộng đồng với logic gating merge.
|
||||
|
||||
## Kiểm soát tác dụng phụ tự động hóa
|
||||
|
||||
- Ưu tiên tự động hóa mang tính quyết định có thể ghi đè thủ công (`risk: manual`) khi ngữ cảnh tinh tế.
|
||||
- Giữ comment auto-response không trùng lặp để tránh nhiễu triage.
|
||||
- Giữ hành vi tự đóng trong phạm vi issue; maintainer quyết định đóng/merge PR.
|
||||
- Nếu tự động hóa sai, sửa nhãn trước, rồi tiếp tục review với lý do rõ ràng.
|
||||
- Dùng nhãn `superseded` / `stale-candidate` để cắt tỉa PR trùng lặp hoặc ngủ đông trước khi review sâu.
|
||||
@@ -1,159 +0,0 @@
|
||||
# Tham khảo lệnh ZeroClaw
|
||||
|
||||
Dựa trên CLI hiện tại (`zeroclaw --help`).
|
||||
|
||||
Xác minh lần cuối: **2026-02-20**.
|
||||
|
||||
## Lệnh cấp cao nhất
|
||||
|
||||
| Lệnh | Mục đích |
|
||||
|---|---|
|
||||
| `onboard` | Khởi tạo workspace/config nhanh hoặc tương tác |
|
||||
| `agent` | Chạy chat tương tác hoặc chế độ gửi tin nhắn đơn |
|
||||
| `gateway` | Khởi động gateway webhook và HTTP WhatsApp |
|
||||
| `daemon` | Khởi động runtime có giám sát (gateway + channels + heartbeat/scheduler tùy chọn) |
|
||||
| `service` | Quản lý vòng đời dịch vụ cấp hệ điều hành |
|
||||
| `doctor` | Chạy chẩn đoán và kiểm tra trạng thái |
|
||||
| `status` | Hiển thị cấu hình và tóm tắt hệ thống |
|
||||
| `cron` | Quản lý tác vụ định kỳ |
|
||||
| `models` | Làm mới danh mục model của provider |
|
||||
| `providers` | Liệt kê ID provider, bí danh và provider đang dùng |
|
||||
| `channel` | Quản lý kênh và kiểm tra sức khỏe kênh |
|
||||
| `integrations` | Kiểm tra chi tiết tích hợp |
|
||||
| `skills` | Liệt kê/cài đặt/gỡ bỏ skills |
|
||||
| `migrate` | Nhập dữ liệu từ runtime khác (hiện hỗ trợ OpenClaw) |
|
||||
| `config` | Xuất schema cấu hình dạng máy đọc được |
|
||||
| `completions` | Tạo script tự hoàn thành cho shell ra stdout |
|
||||
| `hardware` | Phát hiện và kiểm tra phần cứng USB |
|
||||
| `peripheral` | Cấu hình và nạp firmware thiết bị ngoại vi |
|
||||
|
||||
## Nhóm lệnh
|
||||
|
||||
### `onboard`
|
||||
|
||||
- `zeroclaw onboard`
|
||||
- `zeroclaw onboard --channels-only`
|
||||
- `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>`
|
||||
|
||||
### `agent`
|
||||
|
||||
- `zeroclaw agent`
|
||||
- `zeroclaw agent -m "Hello"`
|
||||
- `zeroclaw agent --provider <ID> --model <MODEL> --temperature <0.0-2.0>`
|
||||
- `zeroclaw agent --peripheral <board:path>`
|
||||
|
||||
### `gateway` / `daemon`
|
||||
|
||||
- `zeroclaw gateway [--host <HOST>] [--port <PORT>]`
|
||||
- `zeroclaw daemon [--host <HOST>] [--port <PORT>]`
|
||||
|
||||
### `service`
|
||||
|
||||
- `zeroclaw service install`
|
||||
- `zeroclaw service start`
|
||||
- `zeroclaw service stop`
|
||||
- `zeroclaw service restart`
|
||||
- `zeroclaw service status`
|
||||
- `zeroclaw service uninstall`
|
||||
|
||||
### `cron`
|
||||
|
||||
- `zeroclaw cron list`
|
||||
- `zeroclaw cron add <expr> [--tz <IANA_TZ>] <command>`
|
||||
- `zeroclaw cron add-at <rfc3339_timestamp> <command>`
|
||||
- `zeroclaw cron add-every <every_ms> <command>`
|
||||
- `zeroclaw cron once <delay> <command>`
|
||||
- `zeroclaw cron remove <id>`
|
||||
- `zeroclaw cron pause <id>`
|
||||
- `zeroclaw cron resume <id>`
|
||||
|
||||
### `models`
|
||||
|
||||
- `zeroclaw models refresh`
|
||||
- `zeroclaw models refresh --provider <ID>`
|
||||
- `zeroclaw models refresh --force`
|
||||
|
||||
`models refresh` hiện hỗ trợ làm mới danh mục trực tiếp cho các provider: `openrouter`, `openai`, `anthropic`, `groq`, `mistral`, `deepseek`, `xai`, `together-ai`, `gemini`, `ollama`, `astrai`, `venice`, `fireworks`, `cohere`, `moonshot`, `glm`, `zai`, `qwen` và `nvidia`.
|
||||
|
||||
### `channel`
|
||||
|
||||
- `zeroclaw channel list`
|
||||
- `zeroclaw channel start`
|
||||
- `zeroclaw channel doctor`
|
||||
- `zeroclaw channel bind-telegram <IDENTITY>`
|
||||
- `zeroclaw channel add <type> <json>`
|
||||
- `zeroclaw channel remove <name>`
|
||||
|
||||
Lệnh trong chat khi runtime đang chạy (Telegram/Discord):
|
||||
|
||||
- `/models`
|
||||
- `/models <provider>`
|
||||
- `/model`
|
||||
- `/model <model-id>`
|
||||
|
||||
Channel runtime cũng theo dõi `config.toml` và tự động áp dụng thay đổi cho:
|
||||
- `default_provider`
|
||||
- `default_model`
|
||||
- `default_temperature`
|
||||
- `api_key` / `api_url` (cho provider mặc định)
|
||||
- `reliability.*` cài đặt retry của provider
|
||||
|
||||
`add/remove` hiện chuyển hướng về thiết lập có hướng dẫn / cấu hình thủ công (chưa hỗ trợ đầy đủ mutator khai báo).
|
||||
|
||||
### `integrations`
|
||||
|
||||
- `zeroclaw integrations info <name>`
|
||||
|
||||
### `skills`
|
||||
|
||||
- `zeroclaw skills list`
|
||||
- `zeroclaw skills install <source>`
|
||||
- `zeroclaw skills remove <name>`
|
||||
|
||||
`<source>` chấp nhận git remote (`https://...`, `http://...`, `ssh://...` và `git@host:owner/repo.git`) hoặc đường dẫn cục bộ.
|
||||
|
||||
Skill manifest (`SKILL.toml`) hỗ trợ `prompts` và `[[tools]]`; cả hai được đưa vào system prompt của agent khi chạy, giúp model có thể tuân theo hướng dẫn skill mà không cần đọc thủ công.
|
||||
|
||||
### `migrate`
|
||||
|
||||
- `zeroclaw migrate openclaw [--source <path>] [--dry-run]`
|
||||
|
||||
### `config`
|
||||
|
||||
- `zeroclaw config schema`
|
||||
|
||||
`config schema` xuất JSON Schema (draft 2020-12) cho toàn bộ hợp đồng `config.toml` ra stdout.
|
||||
|
||||
### `completions`
|
||||
|
||||
- `zeroclaw completions bash`
|
||||
- `zeroclaw completions fish`
|
||||
- `zeroclaw completions zsh`
|
||||
- `zeroclaw completions powershell`
|
||||
- `zeroclaw completions elvish`
|
||||
|
||||
`completions` chỉ xuất ra stdout để script có thể được source trực tiếp mà không bị lẫn log/cảnh báo.
|
||||
|
||||
### `hardware`
|
||||
|
||||
- `zeroclaw hardware discover`
|
||||
- `zeroclaw hardware introspect <path>`
|
||||
- `zeroclaw hardware info [--chip <chip_name>]`
|
||||
|
||||
### `peripheral`
|
||||
|
||||
- `zeroclaw peripheral list`
|
||||
- `zeroclaw peripheral add <board> <path>`
|
||||
- `zeroclaw peripheral flash [--port <serial_port>]`
|
||||
- `zeroclaw peripheral setup-uno-q [--host <ip_or_host>]`
|
||||
- `zeroclaw peripheral flash-nucleo`
|
||||
|
||||
## Kiểm tra nhanh
|
||||
|
||||
Để xác minh nhanh tài liệu với binary hiện tại:
|
||||
|
||||
```bash
|
||||
zeroclaw --help
|
||||
zeroclaw <command> --help
|
||||
```
|
||||
@@ -1,521 +0,0 @@
|
||||
# Tham khảo cấu hình ZeroClaw
|
||||
|
||||
Các mục cấu hình thường dùng và giá trị mặc định.
|
||||
|
||||
Xác minh lần cuối: **2026-02-19**.
|
||||
|
||||
Thứ tự tìm config khi khởi động:
|
||||
|
||||
1. Biến `ZEROCLAW_WORKSPACE` (nếu được đặt)
|
||||
2. Marker `~/.zeroclaw/active_workspace.toml` (nếu có)
|
||||
3. Mặc định `~/.zeroclaw/config.toml`
|
||||
|
||||
ZeroClaw ghi log đường dẫn config đã giải quyết khi khởi động ở mức `INFO`:
|
||||
|
||||
- `Config loaded` với các trường: `path`, `workspace`, `source`, `initialized`
|
||||
|
||||
Lệnh xuất schema:
|
||||
|
||||
- `zeroclaw config schema` (xuất JSON Schema draft 2020-12 ra stdout)
|
||||
|
||||
## Khóa chính
|
||||
|
||||
| Khóa | Mặc định | Ghi chú |
|
||||
|---|---|---|
|
||||
| `default_provider` | `openrouter` | ID hoặc bí danh provider |
|
||||
| `default_model` | `anthropic/claude-sonnet-4-6` | Model định tuyến qua provider đã chọn |
|
||||
| `default_temperature` | `0.7` | Nhiệt độ model |
|
||||
|
||||
## `[observability]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `backend` | `none` | Backend quan sát: `none`, `noop`, `log`, `prometheus`, `otel`, `opentelemetry` hoặc `otlp` |
|
||||
| `otel_endpoint` | `http://localhost:4318` | Endpoint OTLP HTTP khi backend là `otel` |
|
||||
| `otel_service_name` | `zeroclaw` | Tên dịch vụ gửi đến OTLP collector |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- `backend = "otel"` dùng OTLP HTTP export với blocking exporter client để span và metric có thể được gửi an toàn từ context ngoài Tokio.
|
||||
- Bí danh `opentelemetry` và `otlp` trỏ đến cùng backend OTel.
|
||||
|
||||
Ví dụ:
|
||||
|
||||
```toml
|
||||
[observability]
|
||||
backend = "otel"
|
||||
otel_endpoint = "http://localhost:4318"
|
||||
otel_service_name = "zeroclaw"
|
||||
```
|
||||
|
||||
## Ghi đè provider qua biến môi trường
|
||||
|
||||
Provider cũng có thể chọn qua biến môi trường. Thứ tự ưu tiên:
|
||||
|
||||
1. `ZEROCLAW_PROVIDER` (ghi đè tường minh, luôn thắng khi có giá trị)
|
||||
2. `PROVIDER` (dự phòng kiểu cũ, chỉ áp dụng khi provider trong config chưa đặt hoặc vẫn là `openrouter`)
|
||||
3. `default_provider` trong `config.toml`
|
||||
|
||||
Lưu ý cho người dùng container:
|
||||
|
||||
- Nếu `config.toml` đặt provider tùy chỉnh như `custom:https://.../v1`, biến `PROVIDER=openrouter` mặc định từ Docker/container sẽ không thay thế nó.
|
||||
- Dùng `ZEROCLAW_PROVIDER` khi cố ý muốn biến môi trường ghi đè provider đã cấu hình.
|
||||
|
||||
## `[agent]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `compact_context` | `false` | Khi bật: bootstrap_max_chars=6000, rag_chunk_limit=2. Dùng cho model 13B trở xuống |
|
||||
| `max_tool_iterations` | `10` | Số vòng lặp tool-call tối đa mỗi tin nhắn trên CLI, gateway và channels |
|
||||
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
|
||||
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
|
||||
| `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 ý:
|
||||
|
||||
- Đặt `max_tool_iterations = 0` sẽ dùng giá trị mặc định an toàn `10`.
|
||||
- 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>]`
|
||||
|
||||
Cấu hình agent phụ (sub-agent). Mỗi khóa dưới `[agents]` định nghĩa một agent phụ có tên mà agent chính có thể ủy quyền.
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `provider` | _bắt buộc_ | Tên provider (ví dụ `"ollama"`, `"openrouter"`, `"anthropic"`) |
|
||||
| `model` | _bắt buộc_ | Tên model cho agent phụ |
|
||||
| `system_prompt` | chưa đặt | System prompt tùy chỉnh cho agent phụ (tùy chọn) |
|
||||
| `api_key` | chưa đặt | API key tùy chỉnh (mã hóa khi `secrets.encrypt = true`) |
|
||||
| `temperature` | chưa đặt | Temperature tùy chỉnh cho agent phụ |
|
||||
| `max_depth` | `3` | Độ sâu đệ quy tối đa cho ủy quyền lồng nhau |
|
||||
| `agentic` | `false` | Bật chế độ vòng lặp tool-call nhiều lượt cho agent phụ |
|
||||
| `allowed_tools` | `[]` | Danh sách tool được phép ở chế độ agentic |
|
||||
| `max_iterations` | `10` | Số vòng tool-call tối đa cho chế độ agentic |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- `agentic = false` giữ nguyên hành vi ủy quyền prompt→response đơn lượt.
|
||||
- `agentic = true` yêu cầu ít nhất một mục khớp trong `allowed_tools`.
|
||||
- Tool `delegate` bị loại khỏi allowlist của agent phụ để tránh vòng lặp ủy quyền.
|
||||
|
||||
```toml
|
||||
[agents.researcher]
|
||||
provider = "openrouter"
|
||||
model = "anthropic/claude-sonnet-4-6"
|
||||
system_prompt = "You are a research assistant."
|
||||
max_depth = 2
|
||||
agentic = true
|
||||
allowed_tools = ["web_search", "http_request", "file_read"]
|
||||
max_iterations = 8
|
||||
|
||||
[agents.coder]
|
||||
provider = "ollama"
|
||||
model = "qwen2.5-coder:32b"
|
||||
temperature = 0.2
|
||||
```
|
||||
|
||||
## `[runtime]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `reasoning_enabled` | chưa đặt (`None`) | Ghi đè toàn cục cho reasoning/thinking trên provider hỗ trợ |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- `reasoning_enabled = false` tắt tường minh reasoning phía provider cho provider hỗ trợ (hiện tại `ollama`, qua trường `think: false`).
|
||||
- `reasoning_enabled = true` yêu cầu reasoning tường minh (`think: true` trên `ollama`).
|
||||
- Để trống giữ mặc định của provider.
|
||||
|
||||
## `[skills]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `open_skills_enabled` | `false` | Cho phép tải/đồng bộ kho `open-skills` cộng đồng |
|
||||
| `open_skills_dir` | chưa đặt | Đường dẫn cục bộ cho `open-skills` (mặc định `$HOME/open-skills` khi bật) |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Mặc định an toàn: ZeroClaw **không** clone hay đồng bộ `open-skills` trừ khi `open_skills_enabled = true`.
|
||||
- Ghi đè qua biến môi trường:
|
||||
- `ZEROCLAW_OPEN_SKILLS_ENABLED` chấp nhận `1/0`, `true/false`, `yes/no`, `on/off`.
|
||||
- `ZEROCLAW_OPEN_SKILLS_DIR` ghi đè đường dẫn kho khi có giá trị.
|
||||
- Thứ tự ưu tiên: `ZEROCLAW_OPEN_SKILLS_ENABLED` → `skills.open_skills_enabled` trong `config.toml` → mặc định `false`.
|
||||
|
||||
## `[composio]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật công cụ OAuth do Composio quản lý |
|
||||
| `api_key` | chưa đặt | API key Composio cho tool `composio` |
|
||||
| `entity_id` | `default` | `user_id` mặc định gửi khi gọi connect/execute |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Tương thích ngược: `enable = true` kiểu cũ được chấp nhận như bí danh cho `enabled = true`.
|
||||
- Nếu `enabled = false` hoặc thiếu `api_key`, tool `composio` không được đăng ký.
|
||||
- ZeroClaw yêu cầu Composio v3 tools với `toolkit_versions=latest` và thực thi với `version="latest"` để tránh bản tool mặc định cũ.
|
||||
- Luồng thông thường: gọi `connect`, hoàn tất OAuth trên trình duyệt, rồi chạy `execute` cho hành động mong muốn.
|
||||
- Nếu Composio trả lỗi thiếu connected-account, gọi `list_accounts` (tùy chọn với `app`) và truyền `connected_account_id` trả về cho `execute`.
|
||||
|
||||
## `[cost]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật theo dõi chi phí |
|
||||
| `daily_limit_usd` | `10.00` | Giới hạn chi tiêu hàng ngày (USD) |
|
||||
| `monthly_limit_usd` | `100.00` | Giới hạn chi tiêu hàng tháng (USD) |
|
||||
| `warn_at_percent` | `80` | Cảnh báo khi chi tiêu đạt tỷ lệ phần trăm này |
|
||||
| `allow_override` | `false` | Cho phép vượt ngân sách khi dùng cờ `--override` |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Khi `enabled = true`, runtime theo dõi ước tính chi phí mỗi yêu cầu và áp dụng giới hạn ngày/tháng.
|
||||
- Tại ngưỡng `warn_at_percent`, cảnh báo được gửi nhưng yêu cầu vẫn tiếp tục.
|
||||
- Khi đạt giới hạn, yêu cầu bị từ chối trừ khi `allow_override = true` và cờ `--override` được truyền.
|
||||
|
||||
## `[identity]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `format` | `openclaw` | Định dạng danh tính: `"openclaw"` (mặc định) hoặc `"aieos"` |
|
||||
| `aieos_path` | chưa đặt | Đường dẫn file AIEOS JSON (tương đối với workspace) |
|
||||
| `aieos_inline` | chưa đặt | AIEOS JSON nội tuyến (thay thế cho đường dẫn file) |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Dùng `format = "aieos"` với `aieos_path` hoặc `aieos_inline` để tải tài liệu danh tính AIEOS / OpenClaw.
|
||||
- Chỉ nên đặt một trong hai `aieos_path` hoặc `aieos_inline`; `aieos_path` được ưu tiên.
|
||||
|
||||
## `[multimodal]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `max_images` | `4` | Số marker ảnh tối đa mỗi yêu cầu |
|
||||
| `max_image_size_mb` | `5` | Giới hạn kích thước ảnh trước khi mã hóa base64 |
|
||||
| `allow_remote_fetch` | `false` | Cho phép tải ảnh từ URL `http(s)` trong marker |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Runtime chấp nhận marker ảnh trong tin nhắn với cú pháp: ``[IMAGE:<source>]``.
|
||||
- Nguồn hỗ trợ:
|
||||
- Đường dẫn file cục bộ (ví dụ ``[IMAGE:/tmp/screenshot.png]``)
|
||||
- Data URI (ví dụ ``[IMAGE:data:image/png;base64,...]``)
|
||||
- URL từ xa chỉ khi `allow_remote_fetch = true`
|
||||
- Kiểu MIME cho phép: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`.
|
||||
- Khi provider đang dùng không hỗ trợ vision, yêu cầu thất bại với lỗi capability có cấu trúc (`capability=vision`) thay vì bỏ qua ảnh.
|
||||
|
||||
## `[browser]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật tool `browser_open` (mở URL trong trình duyệt mặc định hệ thống, không thu thập dữ liệu) |
|
||||
| `allowed_domains` | `[]` | Tên miền cho phép cho `browser_open` (khớp chính xác hoặc subdomain) |
|
||||
| `session_name` | chưa đặt | Tên phiên trình duyệt (cho tự động hóa agent-browser) |
|
||||
| `backend` | `agent_browser` | Backend tự động hóa: `"agent_browser"`, `"rust_native"`, `"computer_use"` hoặc `"auto"` |
|
||||
| `native_headless` | `true` | Chế độ headless cho backend rust-native |
|
||||
| `native_webdriver_url` | `http://127.0.0.1:9515` | URL endpoint WebDriver cho backend rust-native |
|
||||
| `native_chrome_path` | chưa đặt | Đường dẫn Chrome/Chromium tùy chọn cho backend rust-native |
|
||||
|
||||
### `[browser.computer_use]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `endpoint` | `http://127.0.0.1:8787/v1/actions` | Endpoint sidecar cho hành động computer-use (chuột/bàn phím/screenshot cấp OS) |
|
||||
| `api_key` | chưa đặt | Bearer token tùy chọn cho sidecar computer-use (mã hóa khi lưu) |
|
||||
| `timeout_ms` | `15000` | Thời gian chờ mỗi hành động (mili giây) |
|
||||
| `allow_remote_endpoint` | `false` | Cho phép endpoint từ xa/công khai cho sidecar |
|
||||
| `window_allowlist` | `[]` | Danh sách cho phép tiêu đề cửa sổ/tiến trình gửi đến sidecar |
|
||||
| `max_coordinate_x` | chưa đặt | Giới hạn trục X cho hành động dựa trên tọa độ (tùy chọn) |
|
||||
| `max_coordinate_y` | chưa đặt | Giới hạn trục Y cho hành động dựa trên tọa độ (tùy chọn) |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Khi `backend = "computer_use"`, agent ủy quyền hành động trình duyệt cho sidecar tại `computer_use.endpoint`.
|
||||
- `allow_remote_endpoint = false` (mặc định) từ chối mọi endpoint không phải loopback để tránh lộ ra ngoài.
|
||||
- Dùng `window_allowlist` để giới hạn cửa sổ OS mà sidecar có thể tương tác.
|
||||
|
||||
## `[http_request]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật tool `http_request` cho tương tác API |
|
||||
| `allowed_domains` | `[]` | Tên miền cho phép (khớp chính xác hoặc subdomain) |
|
||||
| `max_response_size` | `1000000` | Kích thước response tối đa (byte, mặc định: 1 MB) |
|
||||
| `timeout_secs` | `30` | Thời gian chờ yêu cầu (giây) |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Mặc định từ chối tất cả: nếu `allowed_domains` rỗng, mọi yêu cầu HTTP bị từ chối.
|
||||
- Dùng khớp tên miền chính xác hoặc subdomain (ví dụ `"api.example.com"`, `"example.com"`).
|
||||
|
||||
## `[gateway]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `host` | `127.0.0.1` | Địa chỉ bind |
|
||||
| `port` | `3000` | Cổng lắng nghe gateway |
|
||||
| `require_pairing` | `true` | Yêu cầu ghép nối trước khi xác thực bearer |
|
||||
| `allow_public_bind` | `false` | Chặn lộ public do vô ý |
|
||||
|
||||
## `[autonomy]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `level` | `supervised` | `read_only`, `supervised` hoặc `full` |
|
||||
| `workspace_only` | `true` | Giới hạn ghi/lệnh trong phạm vi workspace |
|
||||
| `allowed_commands` | _bắt buộc để chạy shell_ | Danh sách lệnh được phép |
|
||||
| `forbidden_paths` | `[]` | Danh sách đường dẫn bị cấm |
|
||||
| `max_actions_per_hour` | `100` | Ngân sách hành động mỗi giờ |
|
||||
| `max_cost_per_day_cents` | `1000` | Giới hạn chi tiêu mỗi ngày (cent) |
|
||||
| `require_approval_for_medium_risk` | `true` | Yêu cầu phê duyệt cho lệnh rủi ro trung bình |
|
||||
| `block_high_risk_commands` | `true` | Chặn cứng lệnh rủi ro cao |
|
||||
| `auto_approve` | `[]` | Thao tác tool luôn được tự động phê duyệt |
|
||||
| `always_ask` | `[]` | Thao tác tool luôn yêu cầu phê duyệt |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- `level = "full"` bỏ qua phê duyệt rủi ro trung bình cho shell execution, nhưng vẫn áp dụng guardrail đã cấu hình.
|
||||
- Phân tích toán tử/dấu phân cách shell nhận biết dấu ngoặc kép. Ký tự như `;` trong đối số được trích dẫn được xử lý là ký tự, không phải dấu phân cách lệnh.
|
||||
- Toán tử chuỗi shell không trích dẫn vẫn được kiểm tra bởi policy (`;`, `|`, `&&`, `||`, chạy nền và chuyển hướng).
|
||||
|
||||
## `[memory]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `backend` | `sqlite` | `sqlite`, `lucid`, `markdown`, `none` |
|
||||
| `auto_save` | `true` | Chỉ lưu đầu vào người dùng (đầu ra assistant bị loại) |
|
||||
| `embedding_provider` | `none` | `none`, `openai` hoặc endpoint tùy chỉnh |
|
||||
| `embedding_model` | `text-embedding-3-small` | ID model embedding, hoặc tuyến `hint:<name>` |
|
||||
| `embedding_dimensions` | `1536` | Kích thước vector mong đợi cho model embedding đã chọn |
|
||||
| `vector_weight` | `0.7` | Trọng số vector trong xếp hạng kết hợp |
|
||||
| `keyword_weight` | `0.3` | Trọng số từ khóa trong xếp hạng kết hợp |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Chèn ngữ cảnh memory bỏ qua khóa auto-save `assistant_resp*` kiểu cũ để tránh tóm tắt do model tạo bị coi là sự thật.
|
||||
|
||||
## `[[model_routes]]` và `[[embedding_routes]]`
|
||||
|
||||
Route hint giúp tên tích hợp ổn định khi model ID thay đổi.
|
||||
|
||||
### `[[model_routes]]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `hint` | _bắt buộc_ | Tên hint tác vụ (ví dụ `"reasoning"`, `"fast"`, `"code"`, `"summarize"`) |
|
||||
| `provider` | _bắt buộc_ | Provider đích (phải khớp tên provider đã biết) |
|
||||
| `model` | _bắt buộc_ | Model sử dụng với provider đó |
|
||||
| `api_key` | chưa đặt | API key tùy chỉnh cho provider của route này (tùy chọn) |
|
||||
|
||||
### `[[embedding_routes]]`
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `hint` | _bắt buộc_ | Tên route hint (ví dụ `"semantic"`, `"archive"`, `"faq"`) |
|
||||
| `provider` | _bắt buộc_ | Embedding provider (`"none"`, `"openai"` hoặc `"custom:<url>"`) |
|
||||
| `model` | _bắt buộc_ | Model embedding sử dụng với provider đó |
|
||||
| `dimensions` | chưa đặt | Ghi đè kích thước embedding cho route này (tùy chọn) |
|
||||
| `api_key` | chưa đặt | API key tùy chỉnh cho provider của route này (tùy chọn) |
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
embedding_model = "hint:semantic"
|
||||
|
||||
[[model_routes]]
|
||||
hint = "reasoning"
|
||||
provider = "openrouter"
|
||||
model = "provider/model-id"
|
||||
|
||||
[[embedding_routes]]
|
||||
hint = "semantic"
|
||||
provider = "openai"
|
||||
model = "text-embedding-3-small"
|
||||
dimensions = 1536
|
||||
```
|
||||
|
||||
Chiến lược nâng cấp:
|
||||
|
||||
1. Giữ hint ổn định (`hint:reasoning`, `hint:semantic`).
|
||||
2. Chỉ cập nhật `model = "...phiên-bản-mới..."` trong mục route.
|
||||
3. Kiểm tra bằng `zeroclaw doctor` trước khi khởi động lại/triển khai.
|
||||
|
||||
## `[query_classification]`
|
||||
|
||||
Tự động định tuyến tin nhắn đến hint `[[model_routes]]` theo mẫu nội dung.
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật phân loại truy vấn tự động |
|
||||
| `rules` | `[]` | Quy tắc phân loại (đánh giá theo thứ tự ưu tiên) |
|
||||
|
||||
Mỗi rule trong `rules`:
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `hint` | _bắt buộc_ | Phải khớp giá trị hint trong `[[model_routes]]` |
|
||||
| `keywords` | `[]` | Khớp chuỗi con không phân biệt hoa thường |
|
||||
| `patterns` | `[]` | Khớp chuỗi chính xác phân biệt hoa thường (cho code fence, từ khóa như `"fn "`) |
|
||||
| `min_length` | chưa đặt | Chỉ khớp nếu độ dài tin nhắn ≥ N ký tự |
|
||||
| `max_length` | chưa đặt | Chỉ khớp nếu độ dài tin nhắn ≤ N ký tự |
|
||||
| `priority` | `0` | Rule ưu tiên cao hơn được kiểm tra trước |
|
||||
|
||||
```toml
|
||||
[query_classification]
|
||||
enabled = true
|
||||
|
||||
[[query_classification.rules]]
|
||||
hint = "reasoning"
|
||||
keywords = ["explain", "analyze", "why"]
|
||||
min_length = 200
|
||||
priority = 10
|
||||
|
||||
[[query_classification.rules]]
|
||||
hint = "fast"
|
||||
keywords = ["hi", "hello", "thanks"]
|
||||
max_length = 50
|
||||
priority = 5
|
||||
```
|
||||
|
||||
## `[channels_config]`
|
||||
|
||||
Cấu hình kênh cấp cao nằm dưới `channels_config`.
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `message_timeout_secs` | `300` | Thời gian chờ cơ bản (giây) cho xử lý tin nhắn kênh; runtime tự điều chỉnh theo độ sâu tool-loop (lên đến 4x) |
|
||||
|
||||
Ví dụ:
|
||||
|
||||
- `[channels_config.telegram]`
|
||||
- `[channels_config.discord]`
|
||||
- `[channels_config.whatsapp]`
|
||||
- `[channels_config.email]`
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Mặc định `300s` tối ưu cho LLM chạy cục bộ (Ollama) vốn chậm hơn cloud API.
|
||||
- Ngân sách timeout runtime là `message_timeout_secs * scale`, trong đó `scale = min(max_tool_iterations, 4)` và tối thiểu `1`.
|
||||
- Việc điều chỉnh này tránh timeout sai khi lượt LLM đầu chậm/retry nhưng các lượt tool-loop sau vẫn cần hoàn tất.
|
||||
- Nếu dùng cloud API (OpenAI, Anthropic, v.v.), có thể giảm xuống `60` hoặc thấp hơn.
|
||||
- Giá trị dưới `30` bị giới hạn thành `30` để tránh timeout liên tục.
|
||||
- Khi timeout xảy ra, người dùng nhận: `⚠️ Request timed out while waiting for the model. Please try again.`
|
||||
- Hành vi ngắt chỉ Telegram được điều khiển bằng `channels_config.telegram.interrupt_on_new_message` (mặc định `false`).
|
||||
Khi bật, tin nhắn mới từ cùng người gửi trong cùng chat sẽ hủy yêu cầu đang xử lý và giữ ngữ cảnh người dùng bị ngắt.
|
||||
- Khi `zeroclaw channel start` đang chạy, thay đổi `default_provider`, `default_model`, `default_temperature`, `api_key`, `api_url` và `reliability.*` được áp dụng nóng từ `config.toml` ở tin nhắn tiếp theo.
|
||||
|
||||
Xem ma trận kênh và hành vi allowlist chi tiết tại [channels-reference.md](channels-reference.md).
|
||||
|
||||
### `[channels_config.whatsapp]`
|
||||
|
||||
WhatsApp hỗ trợ hai backend dưới cùng một bảng config.
|
||||
|
||||
Chế độ Cloud API (webhook Meta):
|
||||
|
||||
| Khóa | Bắt buộc | Mục đích |
|
||||
|---|---|---|
|
||||
| `access_token` | Có | Bearer token Meta Cloud API |
|
||||
| `phone_number_id` | Có | ID số điện thoại Meta |
|
||||
| `verify_token` | Có | Token xác minh webhook |
|
||||
| `app_secret` | Tùy chọn | Bật xác minh chữ ký webhook (`X-Hub-Signature-256`) |
|
||||
| `allowed_numbers` | Khuyến nghị | Số điện thoại cho phép gửi đến (`[]` = từ chối tất cả, `"*"` = cho phép tất cả) |
|
||||
|
||||
Chế độ WhatsApp Web (client gốc):
|
||||
|
||||
| Khóa | Bắt buộc | Mục đích |
|
||||
|---|---|---|
|
||||
| `session_path` | Có | Đường dẫn phiên SQLite lưu trữ lâu dài |
|
||||
| `pair_phone` | Tùy chọn | Số điện thoại cho luồng pair-code (chỉ chữ số) |
|
||||
| `pair_code` | Tùy chọn | Mã pair tùy chỉnh (nếu không sẽ tự tạo) |
|
||||
| `allowed_numbers` | Khuyến nghị | Số điện thoại cho phép gửi đến (`[]` = từ chối tất cả, `"*"` = cho phép tất cả) |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- WhatsApp Web yêu cầu build flag `whatsapp-web`.
|
||||
- Nếu cả Cloud lẫn Web đều có cấu hình, Cloud được ưu tiên để tương thích ngược.
|
||||
|
||||
## `[hardware]`
|
||||
|
||||
Cấu hình truy cập phần cứng vật lý (STM32, probe, serial).
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật truy cập phần cứng |
|
||||
| `transport` | `none` | Chế độ truyền: `"none"`, `"native"`, `"serial"` hoặc `"probe"` |
|
||||
| `serial_port` | chưa đặt | Đường dẫn cổng serial (ví dụ `"/dev/ttyACM0"`) |
|
||||
| `baud_rate` | `115200` | Tốc độ baud serial |
|
||||
| `probe_target` | chưa đặt | Chip đích cho probe (ví dụ `"STM32F401RE"`) |
|
||||
| `workspace_datasheets` | `false` | Bật RAG datasheet workspace (đánh chỉ mục PDF schematic để AI tra cứu chân) |
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Dùng `transport = "serial"` với `serial_port` cho kết nối USB-serial.
|
||||
- Dùng `transport = "probe"` với `probe_target` cho nạp qua debug-probe (ví dụ ST-Link).
|
||||
- Xem [hardware-peripherals-design.md](hardware-peripherals-design.md) để biết chi tiết giao thức.
|
||||
|
||||
## `[peripherals]`
|
||||
|
||||
Bo mạch ngoại vi trở thành tool agent khi được bật.
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `enabled` | `false` | Bật hỗ trợ ngoại vi (bo mạch trở thành tool agent) |
|
||||
| `boards` | `[]` | Danh sách cấu hình bo mạch |
|
||||
| `datasheet_dir` | chưa đặt | Đường dẫn tài liệu datasheet (tương đối workspace) cho RAG |
|
||||
|
||||
Mỗi mục trong `boards`:
|
||||
|
||||
| Khóa | Mặc định | Mục đích |
|
||||
|---|---|---|
|
||||
| `board` | _bắt buộc_ | Loại bo mạch: `"nucleo-f401re"`, `"rpi-gpio"`, `"esp32"`, v.v. |
|
||||
| `transport` | `serial` | Kiểu truyền: `"serial"`, `"native"`, `"websocket"` |
|
||||
| `path` | chưa đặt | Đường dẫn serial: `"/dev/ttyACM0"`, `"/dev/ttyUSB0"` |
|
||||
| `baud` | `115200` | Tốc độ baud cho serial |
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
datasheet_dir = "docs/datasheets"
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "rpi-gpio"
|
||||
transport = "native"
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- Đặt file `.md`/`.txt` datasheet đặt tên theo bo mạch (ví dụ `nucleo-f401re.md`, `rpi-gpio.md`) trong `datasheet_dir` cho RAG.
|
||||
- Xem [hardware-peripherals-design.md](hardware-peripherals-design.md) để biết giao thức bo mạch và ghi chú firmware.
|
||||
|
||||
## Giá trị mặc định liên quan bảo mật
|
||||
|
||||
- Allowlist kênh mặc định từ chối tất cả (`[]` nghĩa là từ chối tất cả)
|
||||
- Gateway mặc định yêu cầu ghép nối
|
||||
- Mặc định chặn public bind
|
||||
|
||||
## Lệnh kiểm tra
|
||||
|
||||
Sau khi chỉnh config:
|
||||
|
||||
```bash
|
||||
zeroclaw status
|
||||
zeroclaw doctor
|
||||
zeroclaw channel doctor
|
||||
zeroclaw service restart
|
||||
```
|
||||
|
||||
## Tài liệu liên quan
|
||||
|
||||
- [channels-reference.md](channels-reference.md)
|
||||
- [providers-reference.md](providers-reference.md)
|
||||
- [operations-runbook.md](operations-runbook.md)
|
||||
- [troubleshooting.md](troubleshooting.md)
|
||||
@@ -1,18 +0,0 @@
|
||||
# Tài liệu đóng góp, review và CI
|
||||
|
||||
Dành cho contributor, reviewer và maintainer.
|
||||
|
||||
## Chính sách cốt lõi
|
||||
|
||||
- Hướng dẫn đóng góp: [CONTRIBUTING.md](../../../../CONTRIBUTING.md)
|
||||
- Quy tắc quy trình PR: [../pr-workflow.md](../pr-workflow.md)
|
||||
- Sổ tay reviewer: [../reviewer-playbook.md](../reviewer-playbook.md)
|
||||
- Bản đồ CI và quyền sở hữu: [../ci-map.md](../ci-map.md)
|
||||
- Chính sách nguồn Actions: [../actions-source-policy.md](../actions-source-policy.md)
|
||||
|
||||
## Thứ tự đọc được đề xuất
|
||||
|
||||
1. `CONTRIBUTING.md`
|
||||
2. `../pr-workflow.md`
|
||||
3. `../reviewer-playbook.md`
|
||||
4. `../ci-map.md`
|
||||
@@ -1,111 +0,0 @@
|
||||
# Cấu hình Provider Tùy chỉnh
|
||||
|
||||
ZeroClaw hỗ trợ endpoint API tùy chỉnh cho cả provider tương thích OpenAI lẫn Anthropic.
|
||||
|
||||
## Các loại Provider
|
||||
|
||||
### Endpoint tương thích OpenAI (`custom:`)
|
||||
|
||||
Dành cho các dịch vụ triển khai định dạng API của OpenAI:
|
||||
|
||||
```toml
|
||||
default_provider = "custom:https://your-api.com"
|
||||
api_key = "your-api-key"
|
||||
default_model = "your-model-name"
|
||||
```
|
||||
|
||||
### Endpoint tương thích Anthropic (`anthropic-custom:`)
|
||||
|
||||
Dành cho các dịch vụ triển khai định dạng API của Anthropic:
|
||||
|
||||
```toml
|
||||
default_provider = "anthropic-custom:https://your-api.com"
|
||||
api_key = "your-api-key"
|
||||
default_model = "your-model-name"
|
||||
```
|
||||
|
||||
## Phương thức cấu hình
|
||||
|
||||
### File Config
|
||||
|
||||
Chỉnh sửa `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
api_key = "your-api-key"
|
||||
default_provider = "anthropic-custom:https://api.example.com"
|
||||
default_model = "claude-sonnet-4-6"
|
||||
```
|
||||
|
||||
### Biến môi trường
|
||||
|
||||
Với provider `custom:` và `anthropic-custom:`, dùng biến môi trường chứa key chung:
|
||||
|
||||
```bash
|
||||
export API_KEY="your-api-key"
|
||||
# hoặc: export ZEROCLAW_API_KEY="your-api-key"
|
||||
zeroclaw agent
|
||||
```
|
||||
|
||||
## Kiểm tra cấu hình
|
||||
|
||||
Xác minh endpoint tùy chỉnh của bạn:
|
||||
|
||||
```bash
|
||||
# Chế độ tương tác
|
||||
zeroclaw agent
|
||||
|
||||
# Kiểm tra tin nhắn đơn
|
||||
zeroclaw agent -m "test message"
|
||||
```
|
||||
|
||||
## Xử lý sự cố
|
||||
|
||||
### Lỗi xác thực
|
||||
|
||||
- Kiểm tra lại API key
|
||||
- Kiểm tra định dạng URL endpoint (phải bao gồm `http://` hoặc `https://`)
|
||||
- Đảm bảo endpoint có thể truy cập từ mạng của bạn
|
||||
|
||||
### Không tìm thấy Model
|
||||
|
||||
- Xác nhận tên model khớp với các model mà provider cung cấp
|
||||
- Kiểm tra tài liệu của provider để biết định danh model chính xác
|
||||
- Đảm bảo endpoint và dòng model khớp nhau. Một số gateway tùy chỉnh chỉ cung cấp một tập con model.
|
||||
- Xác minh các model có sẵn từ cùng endpoint và key đã cấu hình:
|
||||
|
||||
```bash
|
||||
curl -sS https://your-api.com/models \
|
||||
-H "Authorization: Bearer $API_KEY"
|
||||
```
|
||||
|
||||
- Nếu gateway không triển khai `/models`, gửi một request chat tối giản và kiểm tra thông báo lỗi model mà provider trả về.
|
||||
|
||||
### Sự cố kết nối
|
||||
|
||||
- Kiểm tra khả năng truy cập endpoint: `curl -I https://your-api.com`
|
||||
- Xác minh cài đặt firewall/proxy
|
||||
- Kiểm tra trang trạng thái của provider
|
||||
|
||||
## Ví dụ
|
||||
|
||||
### LLM Server cục bộ
|
||||
|
||||
```toml
|
||||
default_provider = "custom:http://localhost:8080"
|
||||
default_model = "local-model"
|
||||
```
|
||||
|
||||
### Proxy của doanh nghiệp
|
||||
|
||||
```toml
|
||||
default_provider = "anthropic-custom:https://llm-proxy.corp.example.com"
|
||||
api_key = "internal-token"
|
||||
```
|
||||
|
||||
### Cloud Provider Gateway
|
||||
|
||||
```toml
|
||||
default_provider = "custom:https://gateway.cloud-provider.com/v1"
|
||||
api_key = "gateway-api-key"
|
||||
default_model = "gpt-4"
|
||||
```
|
||||
@@ -1,37 +0,0 @@
|
||||
# Arduino Uno
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| red_led | 13 |
|
||||
| builtin_led | 13 |
|
||||
| user_led | 13 |
|
||||
|
||||
## Tổng quan
|
||||
|
||||
Arduino Uno là board vi điều khiển dựa trên ATmega328P. Có 14 pin digital I/O (0–13) và 6 đầu vào analog (A0–A5).
|
||||
|
||||
## Pin Digital
|
||||
|
||||
- **Pins 0–13:** Digital I/O. Có thể là INPUT hoặc OUTPUT.
|
||||
- **Pin 13:** LED tích hợp (onboard). Kết nối LED với GND hoặc dùng để xuất tín hiệu.
|
||||
- **Pins 0–1:** Cũng dùng cho Serial (RX/TX). Tránh dùng nếu đang sử dụng Serial.
|
||||
|
||||
## GPIO
|
||||
|
||||
- `digitalWrite(pin, HIGH)` hoặc `digitalWrite(pin, LOW)` để xuất tín hiệu.
|
||||
- `digitalRead(pin)` để đọc đầu vào (trả về 0 hoặc 1).
|
||||
- Số pin trong giao thức ZeroClaw: 0–13.
|
||||
|
||||
## Serial
|
||||
|
||||
- UART trên pin 0 (RX) và 1 (TX).
|
||||
- USB qua ATmega16U2 hoặc CH340 (bản clone).
|
||||
- Baud rate: 115200 cho firmware ZeroClaw.
|
||||
|
||||
## ZeroClaw Tools
|
||||
|
||||
- `gpio_read`: Đọc giá trị pin (0 hoặc 1).
|
||||
- `gpio_write`: Đặt pin lên cao (1) hoặc xuống thấp (0).
|
||||
- `arduino_upload`: Agent tạo code Arduino sketch đầy đủ; ZeroClaw biên dịch và tải lên qua arduino-cli. Dùng cho "make a heart", các pattern tùy chỉnh — agent viết code, không cần chỉnh sửa thủ công. Pin 13 = LED tích hợp.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Tham chiếu GPIO ESP32
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| builtin_led | 2 |
|
||||
| red_led | 2 |
|
||||
|
||||
## Các pin thông dụng (ESP32 / ESP32-C3)
|
||||
|
||||
- **GPIO 2**: LED tích hợp trên nhiều dev board (output)
|
||||
- **GPIO 13**: Đầu ra mục đích chung
|
||||
- **GPIO 21/20**: Thường dùng cho UART0 TX/RX (tránh nếu đang dùng serial)
|
||||
|
||||
## Giao thức
|
||||
|
||||
ZeroClaw host gửi JSON qua serial (115200 baud):
|
||||
- `gpio_read`: `{"id":"1","cmd":"gpio_read","args":{"pin":13}}`
|
||||
- `gpio_write`: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`
|
||||
|
||||
Response: `{"id":"1","ok":true,"result":"0"}` hoặc `{"id":"1","ok":true,"result":"done"}`
|
||||
@@ -1,16 +0,0 @@
|
||||
# GPIO Nucleo-F401RE
|
||||
|
||||
## Pin Aliases
|
||||
|
||||
| alias | pin |
|
||||
|-------------|-----|
|
||||
| red_led | 13 |
|
||||
| user_led | 13 |
|
||||
| ld2 | 13 |
|
||||
| builtin_led | 13 |
|
||||
|
||||
## GPIO
|
||||
|
||||
Pin 13: User LED (LD2)
|
||||
- Output, mức cao tích cực (active high)
|
||||
- PA5 trên STM32F401
|
||||
@@ -1,312 +0,0 @@
|
||||
# Bảo mật không gây cản trở
|
||||
|
||||
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
|
||||
>
|
||||
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
|
||||
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Nguyên tắc cốt lõi
|
||||
>
|
||||
> **"Các tính năng bảo mật nên như túi khí — luôn hiện diện, bảo vệ, và vô hình cho đến khi cần."**
|
||||
|
||||
## Thiết kế: tự động phát hiện âm thầm
|
||||
|
||||
### 1. Không thêm bước wizard mới (giữ nguyên 9 bước, < 60 giây)
|
||||
|
||||
```rust
|
||||
// Wizard không thay đổi
|
||||
// Các tính năng bảo mật tự phát hiện ở nền
|
||||
|
||||
pub fn run_wizard() -> Result<Config> {
|
||||
// ... 9 bước hiện có, không thay đổi ...
|
||||
|
||||
let config = Config {
|
||||
// ... các trường hiện có ...
|
||||
|
||||
// MỚI: Bảo mật tự phát hiện (không hiển thị trong wizard)
|
||||
security: SecurityConfig::autodetect(), // Âm thầm!
|
||||
};
|
||||
|
||||
config.save().await?;
|
||||
Ok(config)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Logic tự phát hiện (chạy một lần khi khởi động lần đầu)
|
||||
|
||||
```rust
|
||||
// src/security/detect.rs
|
||||
|
||||
impl SecurityConfig {
|
||||
/// Phát hiện sandbox khả dụng và bật tự động
|
||||
/// Trả về giá trị mặc định thông minh dựa trên nền tảng + công cụ có sẵn
|
||||
pub fn autodetect() -> Self {
|
||||
Self {
|
||||
// Sandbox: ưu tiên Landlock (native), rồi Firejail, rồi none
|
||||
sandbox: SandboxConfig::autodetect(),
|
||||
|
||||
// Resource limits: luôn bật monitoring
|
||||
resources: ResourceLimits::default(),
|
||||
|
||||
// Audit: bật mặc định, log vào config dir
|
||||
audit: AuditConfig::default(),
|
||||
|
||||
// Mọi thứ khác: giá trị mặc định an toàn
|
||||
..SecurityConfig::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxConfig {
|
||||
pub fn autodetect() -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Ưu tiên Landlock (native, không phụ thuộc)
|
||||
if Self::probe_landlock() {
|
||||
return Self {
|
||||
enabled: true,
|
||||
backend: SandboxBackend::Landlock,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: Firejail nếu đã cài
|
||||
if Self::probe_firejail() {
|
||||
return Self {
|
||||
enabled: true,
|
||||
backend: SandboxBackend::Firejail,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Thử Bubblewrap trên macOS
|
||||
if Self::probe_bubblewrap() {
|
||||
return Self {
|
||||
enabled: true,
|
||||
backend: SandboxBackend::Bubblewrap,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: tắt (nhưng vẫn có application-layer security)
|
||||
Self {
|
||||
enabled: false,
|
||||
backend: SandboxBackend::None,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn probe_landlock() -> bool {
|
||||
// Thử tạo Landlock ruleset tối thiểu
|
||||
// Nếu thành công, kernel hỗ trợ Landlock
|
||||
landlock::Ruleset::new()
|
||||
.set_access_fs(landlock::AccessFS::read_file)
|
||||
.add_path(Path::new("/tmp"), landlock::AccessFS::read_file)
|
||||
.map(|ruleset| ruleset.restrict_self().is_ok())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn probe_firejail() -> bool {
|
||||
// Kiểm tra lệnh firejail có tồn tại không
|
||||
std::process::Command::new("firejail")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Lần chạy đầu: ghi log âm thầm
|
||||
|
||||
```bash
|
||||
$ zeroclaw agent -m "hello"
|
||||
|
||||
# Lần đầu: phát hiện âm thầm
|
||||
[INFO] Detecting security features...
|
||||
[INFO] ✓ Landlock sandbox enabled (kernel 6.2+)
|
||||
[INFO] ✓ Memory monitoring active (512MB limit)
|
||||
[INFO] ✓ Audit logging enabled (~/.config/zeroclaw/audit.log)
|
||||
|
||||
# Các lần sau: yên lặng
|
||||
$ zeroclaw agent -m "hello"
|
||||
[agent] Thinking...
|
||||
```
|
||||
|
||||
### 4. File config: tất cả giá trị mặc định được ẩn
|
||||
|
||||
```toml
|
||||
# ~/.config/zeroclaw/config.toml
|
||||
|
||||
# Các section này KHÔNG được ghi trừ khi người dùng tùy chỉnh
|
||||
# [security.sandbox]
|
||||
# enabled = true # (mặc định, tự phát hiện)
|
||||
# backend = "landlock" # (mặc định, tự phát hiện)
|
||||
|
||||
# [security.resources]
|
||||
# max_memory_mb = 512 # (mặc định)
|
||||
|
||||
# [security.audit]
|
||||
# enabled = true # (mặc định)
|
||||
```
|
||||
|
||||
Chỉ khi người dùng thay đổi:
|
||||
```toml
|
||||
[security.sandbox]
|
||||
enabled = false # Người dùng tắt tường minh
|
||||
|
||||
[security.resources]
|
||||
max_memory_mb = 1024 # Người dùng tăng giới hạn
|
||||
```
|
||||
|
||||
### 5. Người dùng nâng cao: kiểm soát tường minh
|
||||
|
||||
```bash
|
||||
# Kiểm tra trạng thái đang hoạt động
|
||||
$ zeroclaw security --status
|
||||
Security Status:
|
||||
✓ Sandbox: Landlock (Linux kernel 6.2)
|
||||
✓ Memory monitoring: 512MB limit
|
||||
✓ Audit logging: ~/.config/zeroclaw/audit.log
|
||||
→ 47 events logged today
|
||||
|
||||
# Tắt sandbox tường minh (ghi vào config)
|
||||
$ zeroclaw config set security.sandbox.enabled false
|
||||
|
||||
# Bật backend cụ thể
|
||||
$ zeroclaw config set security.sandbox.backend firejail
|
||||
|
||||
# Điều chỉnh giới hạn
|
||||
$ zeroclaw config set security.resources.max_memory_mb 2048
|
||||
```
|
||||
|
||||
### 6. Giảm cấp nhẹ nhàng
|
||||
|
||||
| Nền tảng | Tốt nhất có thể | Fallback | Tệ nhất |
|
||||
|----------|---------------|----------|------------|
|
||||
| **Linux 5.13+** | Landlock | None | Chỉ App-layer |
|
||||
| **Linux (bất kỳ)** | Firejail | Landlock | Chỉ App-layer |
|
||||
| **macOS** | Bubblewrap | None | Chỉ App-layer |
|
||||
| **Windows** | None | - | Chỉ App-layer |
|
||||
|
||||
**App-layer security luôn hiện diện** — đây là allowlist/path blocking/injection protection hiện có, vốn đã toàn diện.
|
||||
|
||||
---
|
||||
|
||||
## Mở rộng config schema
|
||||
|
||||
```rust
|
||||
// src/config/schema.rs
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SecurityConfig {
|
||||
/// Cấu hình sandbox (tự phát hiện nếu không đặt)
|
||||
#[serde(default)]
|
||||
pub sandbox: SandboxConfig,
|
||||
|
||||
/// Giới hạn tài nguyên (áp dụng mặc định nếu không đặt)
|
||||
#[serde(default)]
|
||||
pub resources: ResourceLimits,
|
||||
|
||||
/// Audit logging (bật mặc định)
|
||||
#[serde(default)]
|
||||
pub audit: AuditConfig,
|
||||
}
|
||||
|
||||
impl Default for SecurityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sandbox: SandboxConfig::autodetect(), // Phát hiện âm thầm!
|
||||
resources: ResourceLimits::default(),
|
||||
audit: AuditConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxConfig {
|
||||
/// Bật sandboxing (mặc định: tự phát hiện)
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>, // None = tự phát hiện
|
||||
|
||||
/// Sandbox backend (mặc định: tự phát hiện)
|
||||
#[serde(default)]
|
||||
pub backend: SandboxBackend,
|
||||
|
||||
/// Tham số Firejail tùy chỉnh (tùy chọn)
|
||||
#[serde(default)]
|
||||
pub firejail_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SandboxBackend {
|
||||
Auto, // Tự phát hiện (mặc định)
|
||||
Landlock, // Linux kernel LSM
|
||||
Firejail, // User-space sandbox
|
||||
Bubblewrap, // User namespaces
|
||||
Docker, // Container (nặng)
|
||||
None, // Tắt
|
||||
}
|
||||
|
||||
impl Default for SandboxBackend {
|
||||
fn default() -> Self {
|
||||
Self::Auto // Luôn tự phát hiện mặc định
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## So sánh trải nghiệm người dùng
|
||||
|
||||
### Trước (hiện tại)
|
||||
|
||||
```bash
|
||||
$ zeroclaw onboard
|
||||
[1/9] Workspace Setup...
|
||||
[2/9] AI Provider...
|
||||
...
|
||||
[9/9] Workspace Files...
|
||||
✓ Security: Supervised | workspace-scoped
|
||||
```
|
||||
|
||||
### Sau (với bảo mật không gây cản trở)
|
||||
|
||||
```bash
|
||||
$ zeroclaw onboard
|
||||
[1/9] Workspace Setup...
|
||||
[2/9] AI Provider...
|
||||
...
|
||||
[9/9] Workspace Files...
|
||||
✓ Security: Supervised | workspace-scoped | Landlock sandbox ✓
|
||||
# ↑ Chỉ thêm một từ, tự phát hiện âm thầm!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tương thích ngược
|
||||
|
||||
| Tình huống | Hành vi |
|
||||
|----------|----------|
|
||||
| **Config hiện có** | Hoạt động không thay đổi, tính năng mới là opt-in |
|
||||
| **Cài mới** | Tự phát hiện và bật bảo mật khả dụng |
|
||||
| **Không có sandbox** | Fallback về app-layer (vẫn an toàn) |
|
||||
| **Người dùng tắt** | Một flag config: `sandbox.enabled = false` |
|
||||
|
||||
---
|
||||
|
||||
## Tóm tắt
|
||||
|
||||
✅ **Không ảnh hưởng wizard** — giữ nguyên 9 bước, < 60 giây
|
||||
✅ **Không thêm prompt** — tự phát hiện âm thầm
|
||||
✅ **Không breaking change** — tương thích ngược
|
||||
✅ **Có thể opt-out** — flag config tường minh
|
||||
✅ **Hiển thị trạng thái** — `zeroclaw security --status`
|
||||
|
||||
Wizard vẫn là "thiết lập nhanh ứng dụng phổ quát" — bảo mật chỉ **lặng lẽ tốt hơn**.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Tài liệu Bắt đầu
|
||||
|
||||
Dành cho cài đặt lần đầu và làm quen nhanh.
|
||||
|
||||
## Lộ trình bắt đầu
|
||||
|
||||
1. Tổng quan và khởi động nhanh: [../../../README.vi.md](../../../README.vi.md)
|
||||
2. Cài đặt một lệnh và chế độ bootstrap kép: [../one-click-bootstrap.md](../one-click-bootstrap.md)
|
||||
3. Tìm lệnh theo tác vụ: [../commands-reference.md](../commands-reference.md)
|
||||
|
||||
## Chọn hướng đi
|
||||
|
||||
| Tình huống | Lệnh |
|
||||
|----------|---------|
|
||||
| Có API key, muốn cài nhanh nhất | `zeroclaw onboard --api-key sk-... --provider openrouter` |
|
||||
| Muốn được hướng dẫn từng bước | `zeroclaw onboard` |
|
||||
| Đã có config, chỉ cần sửa kênh | `zeroclaw onboard --channels-only` |
|
||||
| Dùng xác thực subscription | Xem [Subscription Auth](../../../README.md#subscription-auth-openai-codex--claude-code) |
|
||||
|
||||
## Thiết lập và kiểm tra
|
||||
|
||||
- Thiết lập nhanh: `zeroclaw onboard --api-key "sk-..." --provider openrouter`
|
||||
- Thiết lập hướng dẫn: `zeroclaw onboard`
|
||||
- Kiểm tra môi trường: `zeroclaw status` + `zeroclaw doctor`
|
||||
|
||||
## Tiếp theo
|
||||
|
||||
- Vận hành runtime: [../operations/README.md](../operations/README.md)
|
||||
- Tra cứu tham khảo: [../reference/README.md](../reference/README.md)
|
||||
@@ -1,324 +0,0 @@
|
||||
# Thiết kế Hardware Peripherals — ZeroClaw
|
||||
|
||||
ZeroClaw cho phép các vi điều khiển (MCU) và máy tính nhúng (SBC) **phân tích lệnh ngôn ngữ tự nhiên theo thời gian thực**, tổng hợp code phù hợp với từng phần cứng, và thực thi tương tác với ngoại vi trực tiếp.
|
||||
|
||||
## 1. Tầm nhìn
|
||||
|
||||
**Mục tiêu:** ZeroClaw đóng vai trò là AI agent có hiểu biết về phần cứng, cụ thể:
|
||||
- Nhận lệnh ngôn ngữ tự nhiên (ví dụ: "Di chuyển cánh tay X", "Bật LED") qua các kênh như WhatsApp, Telegram
|
||||
- Truy xuất tài liệu phần cứng chính xác (datasheet, register map)
|
||||
- Tổng hợp code/logic Rust bằng LLM (Gemini, các mô hình mã nguồn mở)
|
||||
- Thực thi logic để điều khiển ngoại vi (GPIO, I2C, SPI)
|
||||
- Lưu trữ code tối ưu để tái sử dụng về sau
|
||||
|
||||
**Hình dung trực quan:** ZeroClaw = bộ não hiểu phần cứng. Ngoại vi = tay chân mà nó điều khiển.
|
||||
|
||||
## 2. Hai chế độ vận hành
|
||||
|
||||
### Chế độ 1: Edge-Native (Độc lập trên thiết bị)
|
||||
|
||||
**Mục tiêu:** Các board có WiFi (ESP32, Raspberry Pi).
|
||||
|
||||
ZeroClaw chạy **trực tiếp trên thiết bị**. Board khởi động server gRPC/nanoRPC và giao tiếp với ngoại vi ngay tại chỗ.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ZeroClaw on ESP32 / Raspberry Pi (Edge-Native) │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ Channels │───►│ Agent Loop │───►│ RAG: datasheets, register maps │ │
|
||||
│ │ WhatsApp │ │ (LLM calls) │ │ → LLM context │ │
|
||||
│ │ Telegram │ └──────┬───────┘ └─────────────────────────────────┘ │
|
||||
│ └─────────────┘ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────────────────────────────────────────┐│
|
||||
│ │ Code synthesis → Wasm / dynamic exec → GPIO / I2C / SPI → persist ││
|
||||
│ └─────────────────────────────────────────────────────────────────────────┘│
|
||||
│ │
|
||||
│ gRPC/nanoRPC server ◄──► Peripherals (GPIO, I2C, SPI, sensors, actuators) │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Luồng xử lý:**
|
||||
1. Người dùng gửi WhatsApp: *"Turn on LED on pin 13"*
|
||||
2. ZeroClaw truy xuất tài liệu theo board (ví dụ: bản đồ GPIO của ESP32)
|
||||
3. LLM tổng hợp code Rust
|
||||
4. Code chạy trong sandbox (Wasm hoặc dynamic linking)
|
||||
5. GPIO được bật/tắt; kết quả trả về người dùng
|
||||
6. Code tối ưu được lưu lại để tái sử dụng cho các yêu cầu "Turn on LED" sau này
|
||||
|
||||
**Toàn bộ diễn ra trên thiết bị.** Không cần máy chủ trung gian.
|
||||
|
||||
### Chế độ 2: Host-Mediated (Phát triển / Gỡ lỗi)
|
||||
|
||||
**Mục tiêu:** Phần cứng kết nối qua USB / J-Link / Aardvark với máy chủ (macOS, Linux).
|
||||
|
||||
ZeroClaw chạy trên **máy chủ** và duy trì kết nối phần cứng tới thiết bị mục tiêu. Dùng cho phát triển, kiểm tra nội tâm, và nạp firmware.
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ ZeroClaw on Mac │ USB / J-Link / │ STM32 Nucleo-F401RE │
|
||||
│ │ Aardvark │ (or other MCU) │
|
||||
│ - Channels │ ◄────────────────► │ - Memory map │
|
||||
│ - LLM │ │ - Peripherals (GPIO, ADC, I2C) │
|
||||
│ - Hardware probe │ VID/PID │ - Flash / RAM │
|
||||
│ - Flash / debug │ discovery │ │
|
||||
└─────────────────────┘ └──────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Luồng xử lý:**
|
||||
1. Người dùng gửi Telegram: *"What are the readable memory addresses on this USB device?"*
|
||||
2. ZeroClaw nhận diện phần cứng đang kết nối (VID/PID, kiến trúc)
|
||||
3. Thực hiện ánh xạ bộ nhớ; gợi ý các vùng địa chỉ khả dụng
|
||||
4. Trả kết quả về người dùng
|
||||
|
||||
**Hoặc:**
|
||||
1. Người dùng: *"Flash this firmware to the Nucleo"*
|
||||
2. ZeroClaw ghi/nạp firmware qua OpenOCD hoặc probe-rs
|
||||
3. Xác nhận thành công
|
||||
|
||||
**Hoặc:**
|
||||
1. ZeroClaw tự phát hiện: *"STM32 Nucleo on /dev/ttyACM0, ARM Cortex-M4"*
|
||||
2. Gợi ý: *"I can read/write GPIO, ADC, flash. What would you like to do?"*
|
||||
|
||||
---
|
||||
|
||||
### So sánh hai chế độ
|
||||
|
||||
| Khía cạnh | Edge-Native | Host-Mediated |
|
||||
|-----------|-------------|---------------|
|
||||
| ZeroClaw chạy trên | Thiết bị (ESP32, RPi) | Máy chủ (Mac, Linux) |
|
||||
| Kết nối phần cứng | Cục bộ (GPIO, I2C, SPI) | USB, J-Link, Aardvark |
|
||||
| LLM | Trên thiết bị hoặc cloud (Gemini) | Máy chủ (cloud hoặc local) |
|
||||
| Trường hợp sử dụng | Sản xuất, độc lập | Phát triển, gỡ lỗi, kiểm tra |
|
||||
| Kênh liên lạc | WhatsApp, v.v. (qua WiFi) | Telegram, CLI, v.v. |
|
||||
|
||||
## 3. Các chế độ cũ / Đơn giản hơn (Trước khi có LLM trên Edge)
|
||||
|
||||
Dành cho các board không có WiFi hoặc trước khi Edge-Native hoàn chỉnh:
|
||||
|
||||
### Chế độ A: Host + Remote Peripheral (STM32 qua serial)
|
||||
|
||||
Máy chủ chạy ZeroClaw; ngoại vi chạy firmware tối giản. JSON đơn giản qua serial.
|
||||
|
||||
### Chế độ B: RPi làm Host (Native GPIO)
|
||||
|
||||
ZeroClaw trên Pi; GPIO qua rppal hoặc sysfs. Không cần firmware riêng.
|
||||
|
||||
## 4. Yêu cầu kỹ thuật
|
||||
|
||||
| Yêu cầu | Mô tả |
|
||||
|---------|-------|
|
||||
| **Ngôn ngữ** | Thuần Rust. `no_std` khi áp dụng được cho các target nhúng (STM32, ESP32). |
|
||||
| **Giao tiếp** | Stack gRPC hoặc nanoRPC nhẹ để xử lý lệnh với độ trễ thấp. |
|
||||
| **Thực thi động** | Chạy an toàn logic do LLM tạo ra theo thời gian thực: Wasm runtime để cô lập, hoặc dynamic linking khi được hỗ trợ. |
|
||||
| **Truy xuất tài liệu** | Pipeline RAG (Retrieval-Augmented Generation) để đưa đoạn trích datasheet, register map và pinout vào ngữ cảnh LLM. |
|
||||
| **Nhận diện phần cứng** | Nhận dạng thiết bị USB qua VID/PID; phát hiện kiến trúc (ARM Cortex-M, RISC-V, v.v.). |
|
||||
|
||||
### Pipeline RAG (Truy xuất Datasheet)
|
||||
|
||||
- **Lập chỉ mục:** Datasheet, hướng dẫn tham chiếu, register map (PDF → các đoạn, embeddings).
|
||||
- **Truy xuất:** Khi người dùng hỏi ("turn on LED"), lấy các đoạn liên quan (ví dụ: phần GPIO của board mục tiêu).
|
||||
- **Chèn vào:** Thêm vào system prompt hoặc ngữ cảnh LLM.
|
||||
- **Kết quả:** LLM tạo code chính xác, đặc thù cho từng board.
|
||||
|
||||
### Các lựa chọn thực thi động
|
||||
|
||||
| Lựa chọn | Ưu điểm | Nhược điểm |
|
||||
|----------|---------|-----------|
|
||||
| **Wasm** | Sandboxed, di động, không cần FFI | Overhead; truy cập phần cứng từ Wasm bị hạn chế |
|
||||
| **Dynamic linking** | Tốc độ native, truy cập phần cứng đầy đủ | Phụ thuộc nền tảng; lo ngại bảo mật |
|
||||
| **Interpreted DSL** | An toàn, có thể kiểm tra | Chậm hơn; biểu đạt hạn chế |
|
||||
| **Pre-compiled templates** | Nhanh, bảo mật | Kém linh hoạt; cần thư viện template |
|
||||
|
||||
**Khuyến nghị:** Bắt đầu với pre-compiled templates + parameterization; tiến lên Wasm cho logic do người dùng định nghĩa khi đã ổn định.
|
||||
|
||||
## 5. CLI và Config
|
||||
|
||||
### CLI Flags
|
||||
|
||||
```bash
|
||||
# Edge-Native: run on device (ESP32, RPi)
|
||||
zeroclaw agent --mode edge
|
||||
|
||||
# Host-Mediated: connect to USB/J-Link target
|
||||
zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0
|
||||
zeroclaw agent --probe jlink
|
||||
|
||||
# Hardware introspection
|
||||
zeroclaw hardware discover
|
||||
zeroclaw hardware introspect /dev/ttyACM0
|
||||
```
|
||||
|
||||
### Config (config.toml)
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
mode = "host" # "edge" | "host"
|
||||
datasheet_dir = "docs/datasheets" # RAG: board-specific docs for LLM context
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "rpi-gpio"
|
||||
transport = "native"
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "esp32"
|
||||
transport = "wifi"
|
||||
# Edge-Native: ZeroClaw runs on ESP32
|
||||
```
|
||||
|
||||
## 6. Kiến trúc: Peripheral là điểm mở rộng
|
||||
|
||||
### Trait mới: `Peripheral`
|
||||
|
||||
```rust
|
||||
/// A hardware peripheral that exposes capabilities as tools.
|
||||
#[async_trait]
|
||||
pub trait Peripheral: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn board_type(&self) -> &str; // e.g. "nucleo-f401re", "rpi-gpio"
|
||||
async fn connect(&mut self) -> anyhow::Result<()>;
|
||||
async fn disconnect(&mut self) -> anyhow::Result<()>;
|
||||
async fn health_check(&self) -> bool;
|
||||
/// Tools this peripheral provides (gpio_read, gpio_write, sensor_read, etc.)
|
||||
fn tools(&self) -> Vec<Box<dyn Tool>>;
|
||||
}
|
||||
```
|
||||
|
||||
### Luồng xử lý
|
||||
|
||||
1. **Khởi động:** ZeroClaw nạp config, đọc `peripherals.boards`.
|
||||
2. **Kết nối:** Với mỗi board, tạo impl `Peripheral`, gọi `connect()`.
|
||||
3. **Tools:** Thu thập tools từ tất cả peripheral đã kết nối; gộp với tools mặc định.
|
||||
4. **Vòng lặp agent:** Agent có thể gọi `gpio_write`, `sensor_read`, v.v. — các lệnh này chuyển tiếp tới peripheral.
|
||||
5. **Tắt máy:** Gọi `disconnect()` trên từng peripheral.
|
||||
|
||||
### Hỗ trợ Board
|
||||
|
||||
| Board | Transport | Firmware / Driver | Tools |
|
||||
|-------|-----------|-------------------|-------|
|
||||
| nucleo-f401re | serial | Zephyr / Embassy | gpio_read, gpio_write, adc_read |
|
||||
| rpi-gpio | native | rppal or sysfs | gpio_read, gpio_write |
|
||||
| esp32 | serial/ws | ESP-IDF / Embassy | gpio, wifi, mqtt |
|
||||
|
||||
## 7. Giao thức giao tiếp
|
||||
|
||||
### gRPC / nanoRPC (Edge-Native, Host-Mediated)
|
||||
|
||||
Dành cho RPC có kiểu dữ liệu, độ trễ thấp giữa ZeroClaw và các peripheral:
|
||||
|
||||
- **nanoRPC** hoặc **tonic** (gRPC): Dịch vụ định nghĩa bằng Protobuf.
|
||||
- Phương thức: `GpioWrite`, `GpioRead`, `I2cTransfer`, `SpiTransfer`, `MemoryRead`, `FlashWrite`, v.v.
|
||||
- Hỗ trợ streaming, gọi hai chiều, và sinh code từ file `.proto`.
|
||||
|
||||
### Serial Fallback (Host-Mediated, legacy)
|
||||
|
||||
JSON đơn giản qua serial cho các board không hỗ trợ gRPC:
|
||||
|
||||
**Request (host → peripheral):**
|
||||
```json
|
||||
{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}
|
||||
```
|
||||
|
||||
**Response (peripheral → host):**
|
||||
```json
|
||||
{"id":"1","ok":true,"result":"done"}
|
||||
```
|
||||
|
||||
## 8. Firmware (Repo hoặc Crate riêng)
|
||||
|
||||
- **zeroclaw-firmware** hoặc **zeroclaw-peripheral** — một crate/workspace riêng biệt.
|
||||
- Targets: `thumbv7em-none-eabihf` (STM32), `armv7-unknown-linux-gnueabihf` (RPi), v.v.
|
||||
- Dùng `embassy` hoặc Zephyr cho STM32.
|
||||
- Triển khai giao thức nêu trên.
|
||||
- Người dùng nạp lên board; ZeroClaw kết nối và tự phát hiện khả năng.
|
||||
|
||||
## 9. Các giai đoạn triển khai
|
||||
|
||||
### Phase 1: Skeleton ✅ (Hoàn thành)
|
||||
|
||||
- [x] Thêm trait `Peripheral`, config schema, CLI (`zeroclaw peripheral list/add`)
|
||||
- [x] Thêm flag `--peripheral` cho agent
|
||||
- [x] Ghi tài liệu vào AGENTS.md
|
||||
|
||||
### Phase 2: Host-Mediated — Phát hiện phần cứng ✅ (Hoàn thành)
|
||||
|
||||
- [x] `zeroclaw hardware discover`: liệt kê thiết bị USB (VID/PID)
|
||||
- [x] Board registry: ánh xạ VID/PID → kiến trúc, tên (ví dụ: Nucleo-F401RE)
|
||||
- [x] `zeroclaw hardware introspect <path>`: memory map, danh sách peripheral
|
||||
|
||||
### Phase 3: Host-Mediated — Serial / J-Link
|
||||
|
||||
- [x] `SerialPeripheral` cho STM32 qua USB CDC
|
||||
- [ ] Tích hợp probe-rs hoặc OpenOCD để nạp/gỡ lỗi firmware
|
||||
- [x] Tools: `gpio_read`, `gpio_write` (memory_read, flash_write trong tương lai)
|
||||
|
||||
### Phase 4: Pipeline RAG ✅ (Hoàn thành)
|
||||
|
||||
- [x] Lập chỉ mục datasheet (markdown/text → các đoạn)
|
||||
- [x] Truy xuất và chèn vào ngữ cảnh LLM cho các truy vấn liên quan phần cứng
|
||||
- [x] Bổ sung prompt đặc thù theo board
|
||||
|
||||
**Cách dùng:** Thêm `datasheet_dir = "docs/datasheets"` vào `[peripherals]` trong config.toml. Đặt file `.md` hoặc `.txt` được đặt tên theo board (ví dụ: `nucleo-f401re.md`, `rpi-gpio.md`). Các file trong `_generic/` hoặc tên `generic.md` áp dụng cho mọi board. Các đoạn được truy xuất theo từ khóa và chèn vào ngữ cảnh tin nhắn người dùng.
|
||||
|
||||
### Phase 5: Edge-Native — RPi ✅ (Hoàn thành)
|
||||
|
||||
- [x] ZeroClaw trên Raspberry Pi (native GPIO qua rppal)
|
||||
- [ ] Server gRPC/nanoRPC cho truy cập peripheral cục bộ
|
||||
- [ ] Lưu trữ code (lưu các đoạn code đã tổng hợp)
|
||||
|
||||
### Phase 6: Edge-Native — ESP32
|
||||
|
||||
- [x] ESP32 qua Host-Mediated (serial transport) — cùng giao thức JSON như STM32
|
||||
- [x] Crate firmware `esp32` (`firmware/esp32`) — GPIO qua UART
|
||||
- [x] ESP32 trong hardware registry (CH340 VID/PID)
|
||||
- [ ] ZeroClaw *chạy trực tiếp trên* ESP32 (WiFi + LLM, edge-native) — tương lai
|
||||
- [ ] Thực thi Wasm hoặc dựa trên template cho logic do LLM tạo ra
|
||||
|
||||
**Cách dùng:** Nạp `firmware/esp32` vào ESP32, thêm `board = "esp32"`, `transport = "serial"`, `path = "/dev/ttyUSB0"` vào config.
|
||||
|
||||
### Phase 7: Thực thi động (Code do LLM tạo ra)
|
||||
|
||||
- [ ] Thư viện template: các đoạn GPIO/I2C/SPI có tham số
|
||||
- [ ] Tùy chọn: Wasm runtime cho logic do người dùng định nghĩa (sandboxed)
|
||||
- [ ] Lưu và tái sử dụng các đường code tối ưu
|
||||
|
||||
## 10. Các khía cạnh bảo mật
|
||||
|
||||
- **Serial path:** Xác thực `path` nằm trong danh sách cho phép (ví dụ: `/dev/ttyACM*`, `/dev/ttyUSB*`); không bao giờ dùng đường dẫn tùy ý.
|
||||
- **GPIO:** Giới hạn những pin nào được phép truy cập; tránh các pin nguồn/reset.
|
||||
- **Không lưu bí mật trên peripheral:** Firmware không nên lưu API key; máy chủ xử lý xác thực.
|
||||
|
||||
## 11. Ngoài phạm vi (Hiện tại)
|
||||
|
||||
- Chạy ZeroClaw đầy đủ *trực tiếp trên* STM32 bare-metal (không có WiFi, RAM hạn chế) — dùng Host-Mediated thay thế
|
||||
- Đảm bảo thời gian thực — peripheral hoạt động theo kiểu best-effort
|
||||
- Thực thi code native tùy ý từ LLM — ưu tiên Wasm hoặc templates
|
||||
|
||||
## 12. Tài liệu liên quan
|
||||
|
||||
- [adding-boards-and-tools.md](./adding-boards-and-tools.md) — Cách thêm board và datasheet
|
||||
- [network-deployment.md](network-deployment.md) — Triển khai RPi và mạng
|
||||
|
||||
## 13. Tham khảo
|
||||
|
||||
- [Zephyr RTOS Rust support](https://docs.zephyrproject.org/latest/develop/languages/rust/index.html)
|
||||
- [Embassy](https://embassy.dev/) — async embedded framework
|
||||
- [rppal](https://github.com/golemparts/rppal) — Raspberry Pi GPIO in Rust
|
||||
- [STM32 Nucleo-F401RE](https://www.st.com/en/evaluation-tools/nucleo-f401re.html)
|
||||
- [tonic](https://github.com/hyperium/tonic) — gRPC for Rust
|
||||
- [probe-rs](https://probe.rs/) — ARM debug probe, flash, memory access
|
||||
- [nusb](https://github.com/nic-hartley/nusb) — USB device enumeration (VID/PID)
|
||||
|
||||
## 14. Tóm tắt ý tưởng gốc
|
||||
|
||||
> *"Các board như ESP, Raspberry Pi, hoặc các board có WiFi có thể kết nối với LLM (Gemini hoặc mã nguồn mở). ZeroClaw chạy trên thiết bị, tạo gRPC riêng, khởi động nó, và giao tiếp với ngoại vi. Người dùng hỏi qua WhatsApp: 'di chuyển cánh tay X' hoặc 'bật LED'. ZeroClaw lấy tài liệu chính xác, viết code, thực thi, lưu trữ tối ưu, chạy, và bật LED — tất cả trên board phát triển.*
|
||||
>
|
||||
> *Với STM Nucleo kết nối qua USB/J-Link/Aardvark vào Mac: ZeroClaw từ Mac truy cập phần cứng, cài đặt hoặc ghi những gì cần thiết lên thiết bị, và trả kết quả. Ví dụ: 'Hey ZeroClaw, những địa chỉ khả dụng/đọc được trên thiết bị USB này là gì?' Nó có thể tự tìm ra thiết bị nào đang kết nối ở đâu và đưa ra gợi ý."*
|
||||
@@ -1,19 +0,0 @@
|
||||
# Tài liệu phần cứng và ngoại vi
|
||||
|
||||
Tích hợp board, firmware và ngoại vi.
|
||||
|
||||
Hệ thống phần cứng của ZeroClaw cho phép điều khiển trực tiếp vi điều khiển và ngoại vi thông qua trait `Peripheral`. Mỗi board cung cấp các tool cho GPIO, ADC và các thao tác cảm biến, cho phép tương tác phần cứng do agent điều khiển trên các board như STM32 Nucleo, Raspberry Pi và ESP32. Xem [../hardware-peripherals-design.md](../hardware-peripherals-design.md) để biết kiến trúc đầy đủ.
|
||||
|
||||
## Điểm bắt đầu
|
||||
|
||||
- Kiến trúc và mô hình ngoại vi: [../hardware-peripherals-design.md](../hardware-peripherals-design.md)
|
||||
- Thêm board/tool mới: [../adding-boards-and-tools.md](../adding-boards-and-tools.md)
|
||||
- Thiết lập Nucleo: [../nucleo-setup.md](../nucleo-setup.md)
|
||||
- Thiết lập Arduino Uno R4 WiFi: [../arduino-uno-q-setup.md](../arduino-uno-q-setup.md)
|
||||
|
||||
## Datasheet
|
||||
|
||||
- Chỉ mục datasheet: [../datasheets](../datasheets)
|
||||
- STM32 Nucleo-F401RE: [../datasheets/nucleo-f401re.md](../datasheets/nucleo-f401re.md)
|
||||
- Arduino Uno: [../datasheets/arduino-uno.md](../datasheets/arduino-uno.md)
|
||||
- ESP32: [../datasheets/esp32.md](../datasheets/esp32.md)
|
||||
@@ -1,239 +0,0 @@
|
||||
# Hướng dẫn Tích hợp LangGraph
|
||||
|
||||
Hướng dẫn này giải thích cách sử dụng gói Python `zeroclaw-tools` để gọi tool nhất quán với bất kỳ LLM provider nào tương thích OpenAI.
|
||||
|
||||
## Bối cảnh
|
||||
|
||||
Một số LLM provider, đặc biệt là các model Trung Quốc như GLM-5 (Zhipu AI), có hành vi gọi tool không nhất quán khi dùng phương thức text-based tool invocation. Core Rust của ZeroClaw sử dụng structured tool calling theo định dạng OpenAI API, nhưng một số model phản hồi tốt hơn với cách tiếp cận khác.
|
||||
|
||||
LangGraph cung cấp một stateful graph execution engine đảm bảo hành vi gọi tool nhất quán bất kể khả năng native của model nền tảng.
|
||||
|
||||
## Kiến trúc
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Your Application │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ zeroclaw-tools Agent │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ LangGraph StateGraph │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────┐ ┌────────────┐ │ │
|
||||
│ │ │ Agent │ ──────▶ │ Tools │ │ │
|
||||
│ │ │ Node │ ◀────── │ Node │ │ │
|
||||
│ │ └────────────┘ └────────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ ▼ ▼ │ │
|
||||
│ │ [Continue?] [Execute Tool] │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ Yes │ No Result│ │ │
|
||||
│ │ ▼ ▼ │ │
|
||||
│ │ [END] [Back to Agent] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ OpenAI-Compatible LLM Provider │
|
||||
│ (Z.AI, OpenRouter, Groq, DeepSeek, Ollama, etc.) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Bắt đầu nhanh
|
||||
|
||||
### Cài đặt
|
||||
|
||||
```bash
|
||||
pip install zeroclaw-tools
|
||||
```
|
||||
|
||||
### Sử dụng cơ bản
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from zeroclaw_tools import create_agent, shell, file_read, file_write
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
async def main():
|
||||
agent = create_agent(
|
||||
tools=[shell, file_read, file_write],
|
||||
model="glm-5",
|
||||
api_key="your-api-key",
|
||||
base_url="https://api.z.ai/api/coding/paas/v4"
|
||||
)
|
||||
|
||||
result = await agent.ainvoke({
|
||||
"messages": [HumanMessage(content="Read /etc/hostname and tell me the machine name")]
|
||||
})
|
||||
|
||||
print(result["messages"][-1].content)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Các Tool Hiện có
|
||||
|
||||
### Tool cốt lõi
|
||||
|
||||
| Tool | Mô tả |
|
||||
|------|-------|
|
||||
| `shell` | Thực thi lệnh shell |
|
||||
| `file_read` | Đọc nội dung file |
|
||||
| `file_write` | Ghi nội dung vào file |
|
||||
|
||||
### Tool mở rộng
|
||||
|
||||
| Tool | Mô tả |
|
||||
|------|-------|
|
||||
| `web_search` | Tìm kiếm web (yêu cầu `BRAVE_API_KEY`) |
|
||||
| `http_request` | Thực hiện HTTP request |
|
||||
| `memory_store` | Lưu dữ liệu vào bộ nhớ lâu dài |
|
||||
| `memory_recall` | Truy xuất dữ liệu đã lưu |
|
||||
|
||||
## Tool tùy chỉnh
|
||||
|
||||
Tạo tool riêng của bạn bằng decorator `@tool`:
|
||||
|
||||
```python
|
||||
from zeroclaw_tools import tool, create_agent
|
||||
|
||||
@tool
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get the current weather for a city."""
|
||||
# Your implementation
|
||||
return f"Weather in {city}: Sunny, 25°C"
|
||||
|
||||
@tool
|
||||
def query_database(sql: str) -> str:
|
||||
"""Execute a SQL query and return results."""
|
||||
# Your implementation
|
||||
return "Query returned 5 rows"
|
||||
|
||||
agent = create_agent(
|
||||
tools=[get_weather, query_database],
|
||||
model="glm-5",
|
||||
api_key="your-key"
|
||||
)
|
||||
```
|
||||
|
||||
## Cấu hình Provider
|
||||
|
||||
### Z.AI / GLM-5
|
||||
|
||||
```python
|
||||
agent = create_agent(
|
||||
model="glm-5",
|
||||
api_key="your-zhipu-key",
|
||||
base_url="https://api.z.ai/api/coding/paas/v4"
|
||||
)
|
||||
```
|
||||
|
||||
### OpenRouter
|
||||
|
||||
```python
|
||||
agent = create_agent(
|
||||
model="anthropic/claude-sonnet-4-6",
|
||||
api_key="your-openrouter-key",
|
||||
base_url="https://openrouter.ai/api/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### Groq
|
||||
|
||||
```python
|
||||
agent = create_agent(
|
||||
model="llama-3.3-70b-versatile",
|
||||
api_key="your-groq-key",
|
||||
base_url="https://api.groq.com/openai/v1"
|
||||
)
|
||||
```
|
||||
|
||||
### Ollama (cục bộ)
|
||||
|
||||
```python
|
||||
agent = create_agent(
|
||||
model="llama3.2",
|
||||
base_url="http://localhost:11434/v1"
|
||||
)
|
||||
```
|
||||
|
||||
## Tích hợp Discord Bot
|
||||
|
||||
```python
|
||||
import os
|
||||
from zeroclaw_tools.integrations import DiscordBot
|
||||
|
||||
bot = DiscordBot(
|
||||
token=os.environ["DISCORD_TOKEN"],
|
||||
guild_id=123456789, # Your Discord server ID
|
||||
allowed_users=["123456789"], # User IDs that can use the bot
|
||||
api_key=os.environ["API_KEY"],
|
||||
model="glm-5"
|
||||
)
|
||||
|
||||
bot.run()
|
||||
```
|
||||
|
||||
## Sử dụng qua CLI
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export API_KEY="your-key"
|
||||
export BRAVE_API_KEY="your-brave-key" # Optional, for web search
|
||||
|
||||
# Single message
|
||||
zeroclaw-tools "What is the current date?"
|
||||
|
||||
# Interactive mode
|
||||
zeroclaw-tools -i
|
||||
```
|
||||
|
||||
## So sánh với Rust ZeroClaw
|
||||
|
||||
| Khía cạnh | Rust ZeroClaw | zeroclaw-tools |
|
||||
|--------|---------------|-----------------|
|
||||
| **Hiệu năng** | Cực nhanh (~10ms khởi động) | Khởi động Python (~500ms) |
|
||||
| **Bộ nhớ** | <5 MB | ~50 MB |
|
||||
| **Kích thước binary** | ~3.4 MB | pip package |
|
||||
| **Tính nhất quán của tool** | Phụ thuộc model | LangGraph đảm bảo |
|
||||
| **Khả năng mở rộng** | Rust traits | Python decorators |
|
||||
| **Hệ sinh thái** | Rust crates | PyPI packages |
|
||||
|
||||
**Khi nào dùng Rust ZeroClaw:**
|
||||
- Triển khai edge cho môi trường production
|
||||
- Môi trường hạn chế tài nguyên (Raspberry Pi, v.v.)
|
||||
- Yêu cầu hiệu năng tối đa
|
||||
|
||||
**Khi nào dùng zeroclaw-tools:**
|
||||
- Các model có tool calling native không nhất quán
|
||||
- Phát triển trung tâm vào Python
|
||||
- Prototyping nhanh
|
||||
- Tích hợp với hệ sinh thái Python ML
|
||||
|
||||
## Xử lý sự cố
|
||||
|
||||
### Lỗi "API key required"
|
||||
|
||||
Đặt biến môi trường `API_KEY` hoặc truyền `api_key` vào `create_agent()`.
|
||||
|
||||
### Tool call không được thực thi
|
||||
|
||||
Đảm bảo model của bạn hỗ trợ function calling. Một số model cũ có thể không hỗ trợ tool.
|
||||
|
||||
### Rate limiting
|
||||
|
||||
Thêm độ trễ giữa các lần gọi hoặc tự triển khai rate limiting:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
|
||||
for message in messages:
|
||||
result = await agent.ainvoke({"messages": [message]})
|
||||
await asyncio.sleep(1) # Rate limit
|
||||
```
|
||||
|
||||
## Dự án Liên quan
|
||||
|
||||
- [rs-graph-llm](https://github.com/a-agmon/rs-graph-llm) - Rust LangGraph alternative
|
||||
- [langchain-rust](https://github.com/Abraxas-365/langchain-rust) - LangChain for Rust
|
||||
- [llm-chain](https://github.com/sobelio/llm-chain) - LLM chains in Rust
|
||||
@@ -1,141 +0,0 @@
|
||||
# Hướng dẫn Matrix E2EE
|
||||
|
||||
Hướng dẫn này giải thích cách chạy ZeroClaw ổn định trong các phòng Matrix, bao gồm các phòng mã hóa đầu cuối (E2EE).
|
||||
|
||||
Tài liệu tập trung vào lỗi phổ biến mà người dùng báo cáo:
|
||||
|
||||
> "Matrix đã cấu hình đúng, kiểm tra thành công, nhưng bot không phản hồi."
|
||||
|
||||
## 0. FAQ nhanh (triệu chứng lớp #499)
|
||||
|
||||
Nếu Matrix có vẻ đã kết nối nhưng không có phản hồi, hãy xác minh những điều sau trước:
|
||||
|
||||
1. Người gửi được cho phép bởi `allowed_users` (khi kiểm tra: `["*"]`).
|
||||
2. Tài khoản bot đã tham gia đúng phòng mục tiêu.
|
||||
3. Token thuộc về cùng tài khoản bot (kiểm tra bằng `whoami`).
|
||||
4. Phòng mã hóa có identity thiết bị (`device_id`) và chia sẻ key hợp lệ.
|
||||
5. Daemon đã được khởi động lại sau khi thay đổi cấu hình.
|
||||
|
||||
---
|
||||
|
||||
## 1. Yêu cầu
|
||||
|
||||
Trước khi kiểm tra luồng tin nhắn, hãy đảm bảo tất cả các điều sau đều đúng:
|
||||
|
||||
1. Tài khoản bot đã tham gia phòng mục tiêu.
|
||||
2. Access token thuộc về cùng tài khoản bot.
|
||||
3. `room_id` chính xác:
|
||||
- ưu tiên: canonical room ID (`!room:server`)
|
||||
- được hỗ trợ: room alias (`#alias:server`) và ZeroClaw sẽ tự resolve
|
||||
4. `allowed_users` cho phép người gửi (`["*"]` để kiểm tra mở).
|
||||
5. Với phòng E2EE, thiết bị bot đã nhận được encryption key cho phòng.
|
||||
|
||||
---
|
||||
|
||||
## 2. Cấu hình
|
||||
|
||||
Dùng `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[channels_config.matrix]
|
||||
homeserver = "https://matrix.example.com"
|
||||
access_token = "syt_your_token"
|
||||
|
||||
# Optional but recommended for E2EE stability:
|
||||
user_id = "@zeroclaw:matrix.example.com"
|
||||
device_id = "DEVICEID123"
|
||||
|
||||
# Room ID or alias
|
||||
room_id = "!xtHhdHIIVEZbDPvTvZ:matrix.example.com"
|
||||
# room_id = "#ops:matrix.example.com"
|
||||
|
||||
# Use ["*"] during initial verification, then tighten.
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
### Về `user_id` và `device_id`
|
||||
|
||||
- ZeroClaw cố đọc identity từ Matrix `/_matrix/client/v3/account/whoami`.
|
||||
- Nếu `whoami` không trả về `device_id`, hãy đặt `device_id` thủ công.
|
||||
- Các gợi ý này đặc biệt quan trọng để khôi phục phiên E2EE.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quy trình Xác minh Nhanh
|
||||
|
||||
1. Chạy thiết lập channel và daemon:
|
||||
|
||||
```bash
|
||||
zeroclaw onboard --channels-only
|
||||
zeroclaw daemon
|
||||
```
|
||||
|
||||
1. Gửi một tin nhắn văn bản thuần trong phòng Matrix đã cấu hình.
|
||||
|
||||
2. Xác nhận log ZeroClaw có thông tin khởi động Matrix listener và không có lỗi sync/auth lặp lại.
|
||||
|
||||
3. Trong phòng mã hóa, xác minh bot có thể đọc và phản hồi tin nhắn mã hóa từ các người dùng được phép.
|
||||
|
||||
---
|
||||
|
||||
## 4. Xử lý sự cố "Không có Phản hồi"
|
||||
|
||||
Dùng checklist này theo thứ tự.
|
||||
|
||||
### A. Phòng và tư cách thành viên
|
||||
|
||||
- Đảm bảo tài khoản bot đã tham gia phòng.
|
||||
- Nếu dùng alias (`#...`), xác minh nó resolve về đúng canonical room.
|
||||
|
||||
### B. Allowlist người gửi
|
||||
|
||||
- Nếu `allowed_users = []`, tất cả tin nhắn đến đều bị từ chối.
|
||||
- Để chẩn đoán, tạm thời đặt `allowed_users = ["*"]`.
|
||||
|
||||
### C. Token và identity
|
||||
|
||||
- Xác thực token bằng:
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer $MATRIX_TOKEN" \
|
||||
"https://matrix.example.com/_matrix/client/v3/account/whoami"
|
||||
```
|
||||
|
||||
- Kiểm tra `user_id` trả về khớp với tài khoản bot.
|
||||
- Nếu `device_id` bị thiếu, đặt `channels_config.matrix.device_id` thủ công.
|
||||
|
||||
### D. Kiểm tra dành riêng cho E2EE
|
||||
|
||||
- Thiết bị bot phải nhận được room key từ các thiết bị tin cậy.
|
||||
- Nếu key không được chia sẻ tới thiết bị này, các sự kiện mã hóa không thể giải mã.
|
||||
- Xác minh độ tin cậy thiết bị và chia sẻ key trong quy trình Matrix client/admin của bạn.
|
||||
- Nếu log hiện `matrix_sdk_crypto::backups: Trying to backup room keys but no backup key was found`, quá trình khôi phục key backup chưa được bật trên thiết bị này. Cảnh báo này thường không gây lỗi nghiêm trọng cho luồng tin nhắn trực tiếp, nhưng bạn vẫn nên hoàn thiện thiết lập key backup/recovery.
|
||||
- Nếu người nhận thấy tin nhắn bot là "unverified", hãy xác minh/ký thiết bị bot từ một phiên Matrix tin cậy và giữ `channels_config.matrix.device_id` ổn định qua các lần khởi động lại.
|
||||
|
||||
### E. Định dạng tin nhắn (Markdown)
|
||||
|
||||
- ZeroClaw gửi phản hồi văn bản Matrix dưới dạng nội dung `m.room.message` hỗ trợ markdown.
|
||||
- Các Matrix client hỗ trợ `formatted_body` sẽ render in đậm, danh sách và code block.
|
||||
- Nếu định dạng hiển thị dưới dạng văn bản thuần, kiểm tra khả năng của client trước, sau đó xác nhận ZeroClaw đang chạy bản build bao gồm Matrix output hỗ trợ markdown.
|
||||
|
||||
### F. Kiểm tra fresh start
|
||||
|
||||
Sau khi cập nhật cấu hình, khởi động lại daemon và gửi tin nhắn mới (không chỉ xem lại lịch sử cũ).
|
||||
|
||||
---
|
||||
|
||||
## 5. Ghi chú Vận hành
|
||||
|
||||
- Giữ Matrix token tránh khỏi log và ảnh chụp màn hình.
|
||||
- Bắt đầu với `allowed_users` thoáng, sau đó thu hẹp về các user ID cụ thể.
|
||||
- Ưu tiên dùng canonical room ID trong production để tránh alias drift.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tài liệu Liên quan
|
||||
|
||||
- [Channels Reference](./channels-reference.md)
|
||||
- [Phụ lục từ khoá log vận hành](./channels-reference.md#7-operations-appendix-log-keywords-matrix)
|
||||
- [Network Deployment](./network-deployment.md)
|
||||
- [Agnostic Security](agnostic-security.md)
|
||||
- [Reviewer Playbook](reviewer-playbook.md)
|
||||
@@ -1,63 +0,0 @@
|
||||
# Hướng dẫn Tích hợp Mattermost
|
||||
|
||||
ZeroClaw hỗ trợ tích hợp native với Mattermost thông qua REST API v4. Tích hợp này lý tưởng cho các môi trường self-hosted, riêng tư hoặc air-gapped nơi giao tiếp nội bộ là yêu cầu bắt buộc.
|
||||
|
||||
## Điều kiện tiên quyết
|
||||
|
||||
1. **Mattermost Server**: Một instance Mattermost đang chạy (self-hosted hoặc cloud).
|
||||
2. **Tài khoản Bot**:
|
||||
- Vào **Main Menu > Integrations > Bot Accounts**.
|
||||
- Nhấn **Add Bot Account**.
|
||||
- Đặt username (ví dụ: `zeroclaw-bot`).
|
||||
- Bật quyền **post:all** và **channel:read** (hoặc các scope phù hợp).
|
||||
- Lưu **Access Token**.
|
||||
3. **Channel ID**:
|
||||
- Mở channel Mattermost mà bạn muốn bot theo dõi.
|
||||
- Nhấn vào header channel và chọn **View Info**.
|
||||
- Sao chép **ID** (ví dụ: `7j8k9l...`).
|
||||
|
||||
## Cấu hình
|
||||
|
||||
Thêm phần sau vào `config.toml` của bạn trong phần `[channels_config]`:
|
||||
|
||||
```toml
|
||||
[channels_config.mattermost]
|
||||
url = "https://mm.your-domain.com"
|
||||
bot_token = "your-bot-access-token"
|
||||
channel_id = "your-channel-id"
|
||||
allowed_users = ["user-id-1", "user-id-2"]
|
||||
thread_replies = true
|
||||
mention_only = true
|
||||
```
|
||||
|
||||
### Các trường cấu hình
|
||||
|
||||
| Trường | Mô tả |
|
||||
|---|---|
|
||||
| `url` | Base URL của Mattermost server của bạn. |
|
||||
| `bot_token` | Personal Access Token của tài khoản bot. |
|
||||
| `channel_id` | (Tùy chọn) ID của channel cần lắng nghe. Bắt buộc ở chế độ `listen`. |
|
||||
| `allowed_users` | (Tùy chọn) Danh sách Mattermost User ID được phép tương tác với bot. Dùng `["*"]` để cho phép tất cả mọi người. |
|
||||
| `thread_replies` | (Tùy chọn) Tin nhắn người dùng ở top-level có được trả lời trong thread không. Mặc định: `true`. Các phản hồi trong thread hiện có luôn ở lại trong thread đó. |
|
||||
| `mention_only` | (Tùy chọn) Khi `true`, chỉ các tin nhắn đề cập rõ ràng username bot (ví dụ `@zeroclaw-bot`) mới được xử lý. Mặc định: `false`. |
|
||||
|
||||
## Cuộc hội thoại dạng Thread
|
||||
|
||||
ZeroClaw hỗ trợ Mattermost thread ở cả hai chế độ:
|
||||
- Nếu người dùng gửi tin nhắn trong một thread hiện có, ZeroClaw luôn phản hồi trong cùng thread đó.
|
||||
- Nếu `thread_replies = true` (mặc định), tin nhắn top-level được trả lời bằng cách tạo thread trên bài đăng đó.
|
||||
- Nếu `thread_replies = false`, tin nhắn top-level được trả lời ở cấp độ gốc của channel.
|
||||
|
||||
## Chế độ Mention-Only
|
||||
|
||||
Khi `mention_only = true`, ZeroClaw áp dụng bộ lọc bổ sung sau khi xác thực `allowed_users`:
|
||||
|
||||
- Tin nhắn không đề cập rõ ràng đến bot sẽ bị bỏ qua.
|
||||
- Tin nhắn có `@bot_username` sẽ được xử lý.
|
||||
- Token `@bot_username` được loại bỏ trước khi gửi nội dung đến model.
|
||||
|
||||
Chế độ này hữu ích trong các channel chia sẻ bận rộn để giảm các lần gọi model không cần thiết.
|
||||
|
||||
## Ghi chú Bảo mật
|
||||
|
||||
Tích hợp Mattermost được thiết kế cho **giao tiếp nội bộ**. Bằng cách tự host Mattermost server, toàn bộ lịch sử giao tiếp của agent vẫn nằm trong hạ tầng của bạn, tránh việc bên thứ ba ghi lại log.
|
||||
@@ -1,206 +0,0 @@
|
||||
# Triển khai mạng — ZeroClaw trên Raspberry Pi và mạng nội bộ
|
||||
|
||||
Tài liệu này hướng dẫn triển khai ZeroClaw trên Raspberry Pi hoặc host khác trong mạng nội bộ, với các channel Telegram và webhook tùy chọn.
|
||||
|
||||
---
|
||||
|
||||
## 1. Tổng quan
|
||||
|
||||
| Chế độ | Cần cổng đến? | Trường hợp dùng |
|
||||
|------|----------------------|----------|
|
||||
| **Telegram polling** | Không | ZeroClaw poll Telegram API; hoạt động từ bất kỳ đâu |
|
||||
| **Matrix sync (kể cả E2EE)** | Không | ZeroClaw sync qua Matrix client API; không cần webhook đến |
|
||||
| **Discord/Slack** | Không | Tương tự — chỉ outbound |
|
||||
| **Gateway webhook** | Có | POST /webhook, WhatsApp, v.v. cần public URL |
|
||||
| **Gateway pairing** | Có | Nếu bạn pair client qua gateway |
|
||||
|
||||
**Lưu ý:** Telegram, Discord và Slack dùng **long-polling** — ZeroClaw thực hiện các request ra ngoài. Không cần port forwarding hoặc public IP.
|
||||
|
||||
---
|
||||
|
||||
## 2. ZeroClaw trên Raspberry Pi
|
||||
|
||||
### 2.1 Điều kiện tiên quyết
|
||||
|
||||
- Raspberry Pi (3/4/5) với Raspberry Pi OS
|
||||
- Thiết bị ngoại vi USB (Arduino, Nucleo) nếu dùng serial transport
|
||||
- Tùy chọn: `rppal` cho native GPIO (`peripheral-rpi` feature)
|
||||
|
||||
### 2.2 Cài đặt
|
||||
|
||||
```bash
|
||||
# Build for RPi (or cross-compile from host)
|
||||
cargo build --release --features hardware
|
||||
|
||||
# Or install via your preferred method
|
||||
```
|
||||
|
||||
### 2.3 Cấu hình
|
||||
|
||||
Chỉnh sửa `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "rpi-gpio"
|
||||
transport = "native"
|
||||
|
||||
# Or Arduino over USB
|
||||
[[peripherals.boards]]
|
||||
board = "arduino-uno"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
|
||||
[channels_config.telegram]
|
||||
bot_token = "YOUR_BOT_TOKEN"
|
||||
allowed_users = []
|
||||
|
||||
[gateway]
|
||||
host = "127.0.0.1"
|
||||
port = 3000
|
||||
allow_public_bind = false
|
||||
```
|
||||
|
||||
### 2.4 Chạy Daemon (chỉ cục bộ)
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
- Gateway bind vào `127.0.0.1` — không tiếp cận được từ máy khác
|
||||
- Channel Telegram hoạt động: ZeroClaw poll Telegram API (outbound)
|
||||
- Không cần tường lửa hay port forwarding
|
||||
|
||||
---
|
||||
|
||||
## 3. Bind vào 0.0.0.0 (mạng nội bộ)
|
||||
|
||||
Để cho phép các thiết bị khác trong LAN của bạn truy cập gateway (ví dụ: để pairing hoặc webhook):
|
||||
|
||||
### 3.1 Tùy chọn A: Opt-in rõ ràng
|
||||
|
||||
```toml
|
||||
[gateway]
|
||||
host = "0.0.0.0"
|
||||
port = 3000
|
||||
allow_public_bind = true
|
||||
```
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 0.0.0.0 --port 3000
|
||||
```
|
||||
|
||||
**Bảo mật:** `allow_public_bind = true` phơi bày gateway với mạng nội bộ của bạn. Chỉ dùng trên mạng LAN tin cậy.
|
||||
|
||||
### 3.2 Tùy chọn B: Tunnel (khuyến nghị cho Webhook)
|
||||
|
||||
Nếu bạn cần **public URL** (ví dụ: webhook WhatsApp, client bên ngoài):
|
||||
|
||||
1. Chạy gateway trên localhost:
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
2. Khởi động tunnel:
|
||||
```toml
|
||||
[tunnel]
|
||||
provider = "tailscale" # or "ngrok", "cloudflare"
|
||||
```
|
||||
Hoặc dùng `zeroclaw tunnel` (xem tài liệu tunnel).
|
||||
|
||||
3. ZeroClaw sẽ từ chối `0.0.0.0` trừ khi `allow_public_bind = true` hoặc có tunnel đang hoạt động.
|
||||
|
||||
---
|
||||
|
||||
## 4. Telegram Polling (Không cần cổng đến)
|
||||
|
||||
Telegram dùng **long-polling** theo mặc định:
|
||||
|
||||
- ZeroClaw gọi `https://api.telegram.org/bot{token}/getUpdates`
|
||||
- Không cần cổng đến hoặc public IP
|
||||
- Hoạt động sau NAT, trên RPi, trong home lab
|
||||
|
||||
**Cấu hình:**
|
||||
|
||||
```toml
|
||||
[channels_config.telegram]
|
||||
bot_token = "YOUR_BOT_TOKEN"
|
||||
allowed_users = [] # deny-by-default, bind identities explicitly
|
||||
```
|
||||
|
||||
Chạy `zeroclaw daemon` — channel Telegram khởi động tự động.
|
||||
|
||||
Để cho phép một tài khoản Telegram lúc runtime:
|
||||
|
||||
```bash
|
||||
zeroclaw channel bind-telegram <IDENTITY>
|
||||
```
|
||||
|
||||
`<IDENTITY>` có thể là Telegram user ID dạng số hoặc username (không có `@`).
|
||||
|
||||
### 4.1 Quy tắc Single Poller (Quan trọng)
|
||||
|
||||
Telegram Bot API `getUpdates` chỉ hỗ trợ một poller hoạt động cho mỗi bot token.
|
||||
|
||||
- Chỉ chạy một instance runtime cho cùng token (khuyến nghị: service `zeroclaw daemon`).
|
||||
- Không chạy `cargo run -- channel start` hay tiến trình bot khác cùng lúc.
|
||||
|
||||
Nếu gặp lỗi này:
|
||||
|
||||
`Conflict: terminated by other getUpdates request`
|
||||
|
||||
bạn đang có xung đột polling. Dừng các instance thừa và chỉ khởi động lại một daemon duy nhất.
|
||||
|
||||
---
|
||||
|
||||
## 5. Webhook Channel (WhatsApp, Tùy chỉnh)
|
||||
|
||||
Các channel dựa trên webhook cần **public URL** để Meta (WhatsApp) hoặc client của bạn có thể POST sự kiện.
|
||||
|
||||
### 5.1 Tailscale Funnel
|
||||
|
||||
```toml
|
||||
[tunnel]
|
||||
provider = "tailscale"
|
||||
```
|
||||
|
||||
Tailscale Funnel phơi bày gateway của bạn qua URL `*.ts.net`. Không cần port forwarding.
|
||||
|
||||
### 5.2 ngrok
|
||||
|
||||
```toml
|
||||
[tunnel]
|
||||
provider = "ngrok"
|
||||
```
|
||||
|
||||
Hoặc chạy ngrok thủ công:
|
||||
```bash
|
||||
ngrok http 3000
|
||||
# Use the HTTPS URL for your webhook
|
||||
```
|
||||
|
||||
### 5.3 Cloudflare Tunnel
|
||||
|
||||
Cấu hình Cloudflare Tunnel để forward đến `127.0.0.1:3000`, sau đó đặt webhook URL của bạn về hostname công khai của tunnel.
|
||||
|
||||
---
|
||||
|
||||
## 6. Checklist: Triển khai RPi
|
||||
|
||||
- [ ] Build với `--features hardware` (và `peripheral-rpi` nếu dùng native GPIO)
|
||||
- [ ] Cấu hình `[peripherals]` và `[channels_config.telegram]`
|
||||
- [ ] Chạy `zeroclaw daemon --host 127.0.0.1 --port 3000` (Telegram hoạt động không cần 0.0.0.0)
|
||||
- [ ] Để truy cập LAN: `--host 0.0.0.0` + `allow_public_bind = true` trong config
|
||||
- [ ] Để dùng webhook: dùng Tailscale, ngrok hoặc Cloudflare tunnel
|
||||
|
||||
---
|
||||
|
||||
## 7. Tham khảo
|
||||
|
||||
- [channels-reference.md](./channels-reference.md) — Tổng quan cấu hình channel
|
||||
- [matrix-e2ee-guide.md](./matrix-e2ee-guide.md) — Thiết lập Matrix và xử lý sự cố phòng mã hóa
|
||||
- [hardware-peripherals-design.md](hardware-peripherals-design.md) — Thiết kế peripherals
|
||||
- [adding-boards-and-tools.md](adding-boards-and-tools.md) — Thiết lập phần cứng và thêm board
|
||||
@@ -1,147 +0,0 @@
|
||||
# ZeroClaw trên Nucleo-F401RE — Hướng dẫn từng bước
|
||||
|
||||
Chạy ZeroClaw trên Mac hoặc Linux. Kết nối Nucleo-F401RE qua USB. Điều khiển GPIO (LED, các pin) qua Telegram hoặc CLI.
|
||||
|
||||
---
|
||||
|
||||
## Lấy thông tin board qua Telegram (Không cần nạp firmware)
|
||||
|
||||
ZeroClaw có thể đọc thông tin chip từ Nucleo qua USB **mà không cần nạp firmware nào**. Nhắn tin cho Telegram bot của bạn:
|
||||
|
||||
- *"What board info do I have?"*
|
||||
- *"Board info"*
|
||||
- *"What hardware is connected?"*
|
||||
- *"Chip info"*
|
||||
|
||||
Agent dùng tool `hardware_board_info` để trả về tên chip, kiến trúc và memory map. Với feature `probe`, nó đọc dữ liệu trực tiếp qua USB/SWD; nếu không, nó trả về thông tin tĩnh từ datasheet.
|
||||
|
||||
**Cấu hình:** Thêm Nucleo vào `config.toml` trước (để agent biết board nào cần truy vấn):
|
||||
|
||||
```toml
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/ttyACM0"
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
**Thay thế bằng CLI:**
|
||||
|
||||
```bash
|
||||
cargo build --features hardware,probe
|
||||
zeroclaw hardware info
|
||||
zeroclaw hardware discover
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Những gì đã có sẵn (Không cần thay đổi code)
|
||||
|
||||
ZeroClaw bao gồm mọi thứ cần thiết cho Nucleo-F401RE:
|
||||
|
||||
| Thành phần | Vị trí | Mục đích |
|
||||
|------------|--------|---------|
|
||||
| Firmware | `firmware/nucleo/` | Embassy Rust — USART2 (115200), gpio_read, gpio_write |
|
||||
| Serial peripheral | `src/peripherals/serial.rs` | Giao thức JSON-over-serial (giống Arduino/ESP32) |
|
||||
| Flash command | `zeroclaw peripheral flash-nucleo` | Build firmware, nạp qua probe-rs |
|
||||
|
||||
Giao thức: JSON phân tách bằng dòng mới. Request: `{"id":"1","cmd":"gpio_write","args":{"pin":13,"value":1}}`. Response: `{"id":"1","ok":true,"result":"done"}`.
|
||||
|
||||
---
|
||||
|
||||
## Yêu cầu trước khi bắt đầu
|
||||
|
||||
- Board Nucleo-F401RE
|
||||
- Cáp USB (USB-A sang Mini-USB; Nucleo có ST-Link tích hợp sẵn)
|
||||
- Để nạp firmware: `cargo install probe-rs-tools --locked` (hoặc dùng [install script](https://probe.rs/docs/getting-started/installation/))
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Nạp Firmware
|
||||
|
||||
### 1.1 Kết nối Nucleo
|
||||
|
||||
1. Kết nối Nucleo với Mac/Linux qua USB.
|
||||
2. Board xuất hiện như thiết bị USB (ST-Link). Không cần driver riêng trên các hệ thống hiện đại.
|
||||
|
||||
### 1.2 Nạp qua ZeroClaw
|
||||
|
||||
Từ thư mục gốc của repo zeroclaw:
|
||||
|
||||
```bash
|
||||
zeroclaw peripheral flash-nucleo
|
||||
```
|
||||
|
||||
Lệnh này build `firmware/nucleo` và chạy `probe-rs run --chip STM32F401RETx`. Firmware chạy ngay sau khi nạp xong.
|
||||
|
||||
### 1.3 Nạp thủ công (Phương án thay thế)
|
||||
|
||||
```bash
|
||||
cd firmware/nucleo
|
||||
cargo build --release --target thumbv7em-none-eabihf
|
||||
probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/nucleo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Tìm Serial Port
|
||||
|
||||
- **macOS:** `/dev/cu.usbmodem*` hoặc `/dev/tty.usbmodem*` (ví dụ: `/dev/cu.usbmodem101`)
|
||||
- **Linux:** `/dev/ttyACM0` (hoặc kiểm tra `dmesg` sau khi cắm vào)
|
||||
|
||||
USART2 (PA2/PA3) được bridge sang cổng COM ảo của ST-Link, vì vậy máy chủ thấy một thiết bị serial duy nhất.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Cấu hình ZeroClaw
|
||||
|
||||
Thêm vào `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
[peripherals]
|
||||
enabled = true
|
||||
|
||||
[[peripherals.boards]]
|
||||
board = "nucleo-f401re"
|
||||
transport = "serial"
|
||||
path = "/dev/cu.usbmodem101" # điều chỉnh theo port của bạn
|
||||
baud = 115200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Chạy và Kiểm thử
|
||||
|
||||
```bash
|
||||
zeroclaw daemon --host 127.0.0.1 --port 3000
|
||||
```
|
||||
|
||||
Hoặc dùng agent trực tiếp:
|
||||
|
||||
```bash
|
||||
zeroclaw agent --message "Turn on the LED on pin 13"
|
||||
```
|
||||
|
||||
Pin 13 = PA5 = User LED (LD2) trên Nucleo-F401RE.
|
||||
|
||||
---
|
||||
|
||||
## Tóm tắt: Các lệnh
|
||||
|
||||
| Bước | Lệnh |
|
||||
|------|------|
|
||||
| 1 | Kết nối Nucleo qua USB |
|
||||
| 2 | `cargo install probe-rs-tools --locked` |
|
||||
| 3 | `zeroclaw peripheral flash-nucleo` |
|
||||
| 4 | Thêm Nucleo vào config.toml (path = serial port của bạn) |
|
||||
| 5 | `zeroclaw daemon` hoặc `zeroclaw agent -m "Turn on LED"` |
|
||||
|
||||
---
|
||||
|
||||
## Xử lý sự cố
|
||||
|
||||
- **flash-nucleo không nhận ra** — Build từ repo: `cargo run --features hardware -- peripheral flash-nucleo`. Subcommand này chỉ có trong repo build, không có trong cài đặt từ crates.io.
|
||||
- **Không tìm thấy probe-rs** — `cargo install probe-rs-tools --locked` (crate `probe-rs` là thư viện; CLI nằm trong `probe-rs-tools`)
|
||||
- **Không phát hiện được probe** — Đảm bảo Nucleo đã kết nối. Thử cáp/cổng USB khác.
|
||||
- **Không tìm thấy serial port** — Trên Linux, thêm user vào nhóm `dialout`: `sudo usermod -a -G dialout $USER`, rồi đăng xuất/đăng nhập lại.
|
||||
- **Lệnh GPIO bị bỏ qua** — Kiểm tra `path` trong config có khớp với serial port của bạn. Chạy `zeroclaw peripheral list` để xác nhận.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Cài đặt một lệnh
|
||||
|
||||
Cách cài đặt và khởi tạo ZeroClaw nhanh nhất.
|
||||
|
||||
Xác minh lần cuối: **2026-02-20**.
|
||||
|
||||
## Cách 0: Homebrew (macOS/Linuxbrew)
|
||||
|
||||
```bash
|
||||
brew install zeroclaw
|
||||
```
|
||||
|
||||
## Cách A (Khuyến nghị): Clone + chạy script cục bộ
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zeroclaw-labs/zeroclaw.git
|
||||
cd zeroclaw
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Mặc định script sẽ:
|
||||
|
||||
1. `cargo build --release --locked`
|
||||
2. `cargo install --path . --force --locked`
|
||||
|
||||
### Kiểm tra tài nguyên và binary dựng sẵn
|
||||
|
||||
Build từ mã nguồn thường yêu cầu tối thiểu:
|
||||
|
||||
- **2 GB RAM + swap**
|
||||
- **6 GB dung lượng trống**
|
||||
|
||||
Khi tài nguyên hạn chế, bootstrap sẽ thử tải binary dựng sẵn trước.
|
||||
|
||||
```bash
|
||||
./install.sh --prefer-prebuilt
|
||||
```
|
||||
|
||||
Chỉ dùng binary dựng sẵn, báo lỗi nếu không tìm thấy bản phù hợp:
|
||||
|
||||
```bash
|
||||
./install.sh --prebuilt-only
|
||||
```
|
||||
|
||||
Bỏ qua binary dựng sẵn, buộc build từ mã nguồn:
|
||||
|
||||
```bash
|
||||
./install.sh --force-source-build
|
||||
```
|
||||
|
||||
## Bootstrap kép
|
||||
|
||||
Mặc định là **chỉ ứng dụng** (build/cài ZeroClaw), yêu cầu Rust toolchain sẵn có.
|
||||
|
||||
Với máy mới, bật bootstrap môi trường:
|
||||
|
||||
```bash
|
||||
./install.sh --install-system-deps --install-rust
|
||||
```
|
||||
|
||||
Lưu ý:
|
||||
|
||||
- `--install-system-deps` cài các thành phần biên dịch/build cần thiết (có thể cần `sudo`).
|
||||
- `--install-rust` cài Rust qua `rustup` nếu chưa có.
|
||||
- `--prefer-prebuilt` thử tải binary dựng sẵn trước, nếu không có thì build từ nguồn.
|
||||
- `--prebuilt-only` tắt phương án build từ nguồn.
|
||||
- `--force-source-build` tắt hoàn toàn phương án binary dựng sẵn.
|
||||
|
||||
## Cách B: Lệnh từ xa một dòng
|
||||
|
||||
```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.
|
||||
|
||||
Nếu chạy Cách B ngoài thư mục repo, bootstrap script sẽ tự clone workspace tạm, build, cài đặt rồi dọn dẹp.
|
||||
|
||||
## Chế độ thiết lập tùy chọn
|
||||
|
||||
### Thiết lập trong container (Docker)
|
||||
|
||||
```bash
|
||||
./install.sh --docker
|
||||
```
|
||||
|
||||
Lệnh này build image ZeroClaw cục bộ và chạy thiết lập trong container, lưu config/workspace vào `./.zeroclaw-docker`.
|
||||
|
||||
### Thiết lập nhanh (không tương tác)
|
||||
|
||||
```bash
|
||||
./install.sh --api-key "sk-..." --provider openrouter
|
||||
```
|
||||
|
||||
Hoặc dùng biến môi trường:
|
||||
|
||||
```bash
|
||||
ZEROCLAW_API_KEY="sk-..." ZEROCLAW_PROVIDER="openrouter" ./install.sh
|
||||
```
|
||||
|
||||
## Các cờ hữu ích
|
||||
|
||||
- `--install-system-deps`
|
||||
- `--install-rust`
|
||||
- `--skip-build`
|
||||
- `--skip-install`
|
||||
- `--provider <id>`
|
||||
|
||||
Xem tất cả tùy chọn:
|
||||
|
||||
```bash
|
||||
./install.sh --help
|
||||
```
|
||||
|
||||
## Tài liệu liên quan
|
||||
|
||||
- [README.vi.md](../../../README.vi.md)
|
||||
- [commands-reference.md](commands-reference.md)
|
||||
- [providers-reference.md](providers-reference.md)
|
||||
- [channels-reference.md](channels-reference.md)
|
||||
@@ -1,128 +0,0 @@
|
||||
# Sổ tay Vận hành ZeroClaw
|
||||
|
||||
Tài liệu này dành cho các operator chịu trách nhiệm duy trì tính sẵn sàng, tình trạng bảo mật và xử lý sự cố.
|
||||
|
||||
Cập nhật lần cuối: **2026-02-18**.
|
||||
|
||||
## Phạm vi
|
||||
|
||||
Dùng tài liệu này cho các tác vụ vận hành day-2:
|
||||
|
||||
- khởi động và giám sát runtime
|
||||
- kiểm tra sức khoẻ và chẩn đoán hệ thống
|
||||
- triển khai an toàn và rollback
|
||||
- phân loại và khôi phục sau sự cố
|
||||
|
||||
Nếu đây là lần cài đặt đầu tiên, hãy bắt đầu từ [one-click-bootstrap.md](one-click-bootstrap.md).
|
||||
|
||||
## Các chế độ Runtime
|
||||
|
||||
| Chế độ | Lệnh | Khi nào dùng |
|
||||
|---|---|---|
|
||||
| Foreground runtime | `zeroclaw daemon` | gỡ lỗi cục bộ, phiên ngắn |
|
||||
| Foreground gateway only | `zeroclaw gateway` | kiểm thử webhook endpoint |
|
||||
| User service | `zeroclaw service install && zeroclaw service start` | runtime được quản lý liên tục bởi operator |
|
||||
|
||||
## Checklist Cơ bản cho Operator
|
||||
|
||||
1. Xác thực cấu hình:
|
||||
|
||||
```bash
|
||||
zeroclaw status
|
||||
```
|
||||
|
||||
1. Kiểm tra chẩn đoán:
|
||||
|
||||
```bash
|
||||
zeroclaw doctor
|
||||
zeroclaw channel doctor
|
||||
```
|
||||
|
||||
1. Khởi động runtime:
|
||||
|
||||
```bash
|
||||
zeroclaw daemon
|
||||
```
|
||||
|
||||
1. Để chạy như user session service liên tục:
|
||||
|
||||
```bash
|
||||
zeroclaw service install
|
||||
zeroclaw service start
|
||||
zeroclaw service status
|
||||
```
|
||||
|
||||
## Tín hiệu Sức khoẻ và Trạng thái
|
||||
|
||||
| Tín hiệu | Lệnh / File | Kỳ vọng |
|
||||
|---|---|---|
|
||||
| Tính hợp lệ của config | `zeroclaw doctor` | không có lỗi nghiêm trọng |
|
||||
| Kết nối channel | `zeroclaw channel doctor` | các channel đã cấu hình đều khoẻ mạnh |
|
||||
| Tóm tắt runtime | `zeroclaw status` | provider/model/channels như mong đợi |
|
||||
| Heartbeat/trạng thái daemon | `~/.zeroclaw/daemon_state.json` | file được cập nhật định kỳ |
|
||||
|
||||
## Log và Chẩn đoán
|
||||
|
||||
### macOS / Windows (log của service wrapper)
|
||||
|
||||
- `~/.zeroclaw/logs/daemon.stdout.log`
|
||||
- `~/.zeroclaw/logs/daemon.stderr.log`
|
||||
|
||||
### Linux (systemd user service)
|
||||
|
||||
```bash
|
||||
journalctl --user -u zeroclaw.service -f
|
||||
```
|
||||
|
||||
## Quy trình Phân loại Sự cố (Fast Path)
|
||||
|
||||
1. Chụp trạng thái hệ thống:
|
||||
|
||||
```bash
|
||||
zeroclaw status
|
||||
zeroclaw doctor
|
||||
zeroclaw channel doctor
|
||||
```
|
||||
|
||||
1. Kiểm tra trạng thái service:
|
||||
|
||||
```bash
|
||||
zeroclaw service status
|
||||
```
|
||||
|
||||
1. Nếu service không khoẻ, khởi động lại sạch:
|
||||
|
||||
```bash
|
||||
zeroclaw service stop
|
||||
zeroclaw service start
|
||||
```
|
||||
|
||||
1. Nếu các channel vẫn thất bại, kiểm tra allowlist và thông tin xác thực trong `~/.zeroclaw/config.toml`.
|
||||
|
||||
2. Nếu liên quan đến gateway, kiểm tra cài đặt bind/auth (`[gateway]`) và khả năng tiếp cận cục bộ.
|
||||
|
||||
## Quy trình Thay đổi An toàn
|
||||
|
||||
Trước khi áp dụng thay đổi cấu hình:
|
||||
|
||||
1. sao lưu `~/.zeroclaw/config.toml`
|
||||
2. chỉ áp dụng một thay đổi logic tại một thời điểm
|
||||
3. chạy `zeroclaw doctor`
|
||||
4. khởi động lại daemon/service
|
||||
5. xác minh bằng `status` + `channel doctor`
|
||||
|
||||
## Quy trình Rollback
|
||||
|
||||
Nếu một lần triển khai gây ra suy giảm hành vi:
|
||||
|
||||
1. khôi phục `config.toml` trước đó
|
||||
2. khởi động lại runtime (`daemon` hoặc `service`)
|
||||
3. xác nhận khôi phục qua `doctor` và kiểm tra sức khoẻ channel
|
||||
4. ghi lại nguyên nhân gốc rễ và biện pháp khắc phục sự cố
|
||||
|
||||
## Tài liệu Liên quan
|
||||
|
||||
- [one-click-bootstrap.md](one-click-bootstrap.md)
|
||||
- [troubleshooting.md](troubleshooting.md)
|
||||
- [config-reference.md](config-reference.md)
|
||||
- [commands-reference.md](commands-reference.md)
|
||||
@@ -1,24 +0,0 @@
|
||||
# Tài liệu vận hành và triển khai
|
||||
|
||||
Dành cho operator vận hành ZeroClaw liên tục hoặc trên production.
|
||||
|
||||
## Vận hành cốt lõi
|
||||
|
||||
- Sổ tay Day-2: [../operations-runbook.md](../operations-runbook.md)
|
||||
- Sổ tay Release: [../release-process.md](../release-process.md)
|
||||
- Ma trận xử lý sự cố: [../troubleshooting.md](../troubleshooting.md)
|
||||
- Triển khai mạng/gateway an toàn: [../network-deployment.md](../network-deployment.md)
|
||||
- Thiết lập Mattermost (dành riêng cho channel): [../mattermost-setup.md](../mattermost-setup.md)
|
||||
|
||||
## Luồng thường gặp
|
||||
|
||||
1. Xác thực runtime (`status`, `doctor`, `channel doctor`)
|
||||
2. Áp dụng từng thay đổi config một lần
|
||||
3. Khởi động lại service/daemon
|
||||
4. Xác minh tình trạng channel và gateway
|
||||
5. Rollback nhanh nếu hành vi bị hồi quy
|
||||
|
||||
## Liên quan
|
||||
|
||||
- Tham chiếu config: [../config-reference.md](../config-reference.md)
|
||||
- Bộ sưu tập bảo mật: [../security/README.md](../security/README.md)
|
||||
@@ -1,366 +0,0 @@
|
||||
# Quy trình PR ZeroClaw (Cộng tác khối lượng cao)
|
||||
|
||||
Tài liệu này định nghĩa cách ZeroClaw xử lý khối lượng PR lớn trong khi vẫn duy trì:
|
||||
|
||||
- Hiệu suất cao
|
||||
- Hiệu quả cao
|
||||
- Tính ổn định cao
|
||||
- Khả năng mở rộng cao
|
||||
- Tính bền vững cao
|
||||
- Bảo mật cao
|
||||
|
||||
Tài liệu liên quan:
|
||||
|
||||
- [`docs/README.md`](README.md) — phân loại và điều hướng tài liệu.
|
||||
- [`docs/ci-map.md`](ci-map.md) — quyền sở hữu từng workflow, trigger và luồng triage.
|
||||
- [`docs/reviewer-playbook.md`](reviewer-playbook.md) — hướng dẫn thực thi cho reviewer hàng ngày.
|
||||
|
||||
## 0. Tóm tắt
|
||||
|
||||
- **Mục đích:** cung cấp mô hình vận hành PR mang tính quyết định và dựa trên rủi ro cho cộng tác thông lượng cao.
|
||||
- **Đối tượng:** contributor, maintainer và reviewer có hỗ trợ agent.
|
||||
- **Phạm vi:** cài đặt repository, vòng đời PR, hợp đồng sẵn sàng, phân tuyến rủi ro, kỷ luật hàng đợi và giao thức phục hồi.
|
||||
- **Ngoài phạm vi:** thay thế cấu hình branch protection hoặc file CI workflow làm nguồn triển khai chính thức.
|
||||
|
||||
---
|
||||
|
||||
## 1. Lối tắt theo tình huống PR
|
||||
|
||||
Dùng phần này để phân tuyến nhanh trước khi review sâu toàn bộ.
|
||||
|
||||
### 1.1 Intake chưa đầy đủ
|
||||
|
||||
1. Yêu cầu hoàn thiện template và bằng chứng còn thiếu trong một comment dạng checklist.
|
||||
2. Dừng review sâu cho đến khi các vấn đề intake được giải quyết.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 5.1](#51-definition-of-ready-dor-trước-khi-yêu-cầu-review)
|
||||
|
||||
### 1.2 `CI Required Gate` đang thất bại
|
||||
|
||||
1. Phân tuyến lỗi qua CI map và ưu tiên sửa các gate mang tính quyết định trước.
|
||||
2. Chỉ đánh giá lại rủi ro sau khi CI trả về tín hiệu rõ ràng.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [docs/ci-map.md](ci-map.md)
|
||||
- [Mục 4.2](#42-bước-b-validation)
|
||||
|
||||
### 1.3 Đụng đến đường dẫn rủi ro cao
|
||||
|
||||
1. Chuyển sang luồng review sâu.
|
||||
2. Yêu cầu rollback rõ ràng, bằng chứng về failure mode và kiểm tra ranh giới bảo mật.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 9](#9-quy-tắc-bảo-mật-và-ổn-định)
|
||||
- [docs/reviewer-playbook.md](reviewer-playbook.md)
|
||||
|
||||
### 1.4 PR bị supersede hoặc trùng lặp
|
||||
|
||||
1. Yêu cầu liên kết supersede rõ ràng và dọn dẹp hàng đợi.
|
||||
2. Đóng PR bị supersede sau khi maintainer xác nhận.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 8.2](#82-kiểm-soát-áp-lực-backlog)
|
||||
|
||||
---
|
||||
|
||||
## 2. Mục tiêu quản trị và vòng kiểm soát
|
||||
|
||||
### 2.1 Mục tiêu quản trị
|
||||
|
||||
1. Giữ thông lượng merge có thể dự đoán được khi tải PR lớn.
|
||||
2. Giữ chất lượng tín hiệu CI ở mức cao (phản hồi nhanh, ít false positive).
|
||||
3. Giữ review bảo mật rõ ràng đối với các bề mặt rủi ro.
|
||||
4. Giữ các thay đổi dễ suy luận và dễ hoàn tác.
|
||||
5. Giữ các artifact trong repository không bị rò rỉ dữ liệu cá nhân/nhạy cảm.
|
||||
|
||||
### 2.2 Logic thiết kế quản trị (vòng kiểm soát)
|
||||
|
||||
Workflow này được phân lớp có chủ đích để giảm tải cho reviewer trong khi vẫn đảm bảo trách nhiệm rõ ràng:
|
||||
|
||||
1. **Phân loại intake:** nhãn theo đường dẫn/kích thước/rủi ro/module phân tuyến PR đến độ sâu review phù hợp.
|
||||
2. **Validation mang tính quyết định:** merge gate phụ thuộc vào các kiểm tra tái tạo được, không phải comment mang tính chủ quan.
|
||||
3. **Độ sâu review theo rủi ro:** đường dẫn rủi ro cao kích hoạt review sâu; đường dẫn rủi ro thấp được xử lý nhanh.
|
||||
4. **Hợp đồng merge ưu tiên rollback:** mọi đường dẫn merge đều bao gồm các bước phục hồi cụ thể.
|
||||
|
||||
Tự động hóa hỗ trợ việc triage và bảo vệ, nhưng trách nhiệm merge cuối cùng vẫn thuộc về maintainer và tác giả PR.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cài đặt repository bắt buộc
|
||||
|
||||
Duy trì các quy tắc branch protection sau trên `master`:
|
||||
|
||||
- Yêu cầu status check trước khi merge.
|
||||
- Yêu cầu check `CI Required Gate`.
|
||||
- Yêu cầu review pull request trước khi merge.
|
||||
- Yêu cầu review CODEOWNERS cho các đường dẫn được bảo vệ.
|
||||
- Với `.github/workflows/**`, yêu cầu phê duyệt từ owner qua `CI Required Gate` (`WORKFLOW_OWNER_LOGINS`) và giới hạn quyền bypass branch/ruleset cho org owner.
|
||||
- Danh sách workflow-owner mặc định được cấu hình qua biến repository `WORKFLOW_OWNER_LOGINS` (xem CODEOWNERS cho maintainer hiện tại).
|
||||
- Hủy bỏ approval cũ khi có commit mới được đẩy lên.
|
||||
- Hạn chế force-push trên các branch được bảo vệ.
|
||||
- Tất cả PR của contributor nhắm trực tiếp vào `master`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sổ tay vòng đời PR
|
||||
|
||||
### 4.1 Bước A: Intake
|
||||
|
||||
- Contributor mở PR với `.github/pull_request_template.md` đầy đủ.
|
||||
- `PR Labeler` áp dụng nhãn phạm vi/đường dẫn + nhãn kích thước + nhãn rủi ro + nhãn module (ví dụ `channel:telegram`, `provider:kimi`, `tool:shell`) và bậc contributor theo số PR đã merge (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), đồng thời loại bỏ trùng lặp nhãn phạm vi ít cụ thể hơn khi đã có nhãn module cụ thể hơn.
|
||||
- Đối với tất cả các tiền tố module, nhãn module được nén gọn để giảm nhiễu: một module cụ thể giữ `prefix:component`, nhưng nhiều module cụ thể thu gọn thành nhãn phạm vi cơ sở `prefix`.
|
||||
- Thứ tự nhãn ưu tiên đầu tiên: `risk:*` -> `size:*` -> bậc contributor -> nhãn module/đường dẫn.
|
||||
- Maintainer có thể chạy `PR Labeler` thủ công (`workflow_dispatch`) ở chế độ `audit` để kiểm tra drift hoặc chế độ `repair` để chuẩn hóa metadata nhãn được quản lý trên toàn repository.
|
||||
- Di chuột qua nhãn trên GitHub hiển thị mô tả được quản lý tự động (tóm tắt quy tắc/ngưỡng).
|
||||
- Màu nhãn được quản lý được sắp xếp theo thứ tự hiển thị để tạo gradient mượt mà trên các hàng nhãn dài.
|
||||
- `PR Auto Responder` đăng hướng dẫn lần đầu, xử lý phân tuyến dựa trên nhãn cho các mục tín hiệu thấp và tự động áp dụng bậc contributor cho issue với cùng ngưỡng như `PR Labeler` (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50).
|
||||
|
||||
### 4.2 Bước B: Validation
|
||||
|
||||
- `CI Required Gate` là merge gate.
|
||||
- PR chỉ thay đổi tài liệu sử dụng fast-path và bỏ qua các Rust job nặng.
|
||||
- PR không phải tài liệu phải vượt qua lint, test và kiểm tra smoke release build.
|
||||
- PR ảnh hưởng Rust sử dụng cùng bộ gate bắt buộc như push lên `master` (không có shortcut chỉ build trên PR).
|
||||
|
||||
### 4.3 Bước C: Review
|
||||
|
||||
- Reviewer ưu tiên theo nhãn rủi ro và kích thước.
|
||||
- Các đường dẫn nhạy cảm về bảo mật (`src/security`, `src/runtime`, `src/gateway` và CI workflow) yêu cầu sự chú ý của maintainer.
|
||||
- PR lớn (`size: L`/`size: XL`) nên được chia nhỏ trừ khi có lý do thuyết phục.
|
||||
|
||||
### 4.4 Bước D: Merge
|
||||
|
||||
- Ưu tiên **squash merge** để giữ lịch sử gọn gàng.
|
||||
- Tiêu đề PR nên theo phong cách Conventional Commit.
|
||||
- Chỉ merge khi đường dẫn rollback đã được ghi lại.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hợp đồng sẵn sàng PR (DoR / DoD)
|
||||
|
||||
### 5.1 Definition of Ready (DoR) trước khi yêu cầu review
|
||||
|
||||
- Template PR đã hoàn thiện đầy đủ.
|
||||
- Ranh giới phạm vi rõ ràng (những gì đã thay đổi / những gì không thay đổi).
|
||||
- Bằng chứng validation đã đính kèm (không chỉ là "CI sẽ kiểm tra").
|
||||
- Các trường bảo mật và rollback đã hoàn thành cho các đường dẫn rủi ro.
|
||||
- Kiểm tra tính riêng tư/vệ sinh dữ liệu đã hoàn thành và ngôn ngữ test trung lập/theo phạm vi dự án.
|
||||
- Nếu có ngôn ngữ giống danh tính trong test/ví dụ, cần được chuẩn hóa về nhãn gốc ZeroClaw/dự án.
|
||||
|
||||
### 5.2 Definition of Done (DoD) sẵn sàng merge
|
||||
|
||||
- `CI Required Gate` đã xanh.
|
||||
- Các reviewer bắt buộc đã phê duyệt (bao gồm các đường dẫn CODEOWNERS).
|
||||
- Nhãn phân loại rủi ro khớp với các đường dẫn đã chạm.
|
||||
- Tác động migration/tương thích đã được ghi lại.
|
||||
- Đường dẫn rollback cụ thể và nhanh chóng.
|
||||
|
||||
---
|
||||
|
||||
## 6. Chính sách kích thước và lô PR
|
||||
|
||||
### 6.1 Phân loại kích thước
|
||||
|
||||
- `size: XS` <= 80 dòng thay đổi
|
||||
- `size: S` <= 250 dòng thay đổi
|
||||
- `size: M` <= 500 dòng thay đổi
|
||||
- `size: L` <= 1000 dòng thay đổi
|
||||
- `size: XL` > 1000 dòng thay đổi
|
||||
|
||||
### 6.2 Chính sách
|
||||
|
||||
- Mặc định hướng đến `XS/S/M`.
|
||||
- PR `L/XL` cần lý do biện minh rõ ràng và bằng chứng test chặt chẽ hơn.
|
||||
- Nếu tính năng lớn không thể tránh khỏi, chia thành các stacked PR.
|
||||
|
||||
### 6.3 Hành vi tự động hóa
|
||||
|
||||
- `PR Labeler` áp dụng nhãn `size:*` từ số dòng thay đổi thực tế.
|
||||
- PR chỉ tài liệu/nặng lockfile được chuẩn hóa để tránh thổi phồng kích thước.
|
||||
|
||||
---
|
||||
|
||||
## 7. Chính sách đóng góp AI/Agent
|
||||
|
||||
PR có sự hỗ trợ AI được chào đón, và review cũng có thể được hỗ trợ bằng agent.
|
||||
|
||||
### 7.1 Bắt buộc
|
||||
|
||||
1. Tóm tắt PR rõ ràng với ranh giới phạm vi.
|
||||
2. Bằng chứng test/validation cụ thể.
|
||||
3. Ghi chú tác động bảo mật và rollback cho các thay đổi rủi ro.
|
||||
|
||||
### 7.2 Khuyến nghị
|
||||
|
||||
1. Ghi chú ngắn gọn về tool/workflow khi tự động hóa ảnh hưởng đáng kể đến thay đổi.
|
||||
2. Đoạn prompt/kế hoạch tùy chọn để tái tạo được.
|
||||
|
||||
Chúng tôi **không** yêu cầu contributor định lượng quyền sở hữu dòng AI-vs-human.
|
||||
|
||||
### 7.3 Trọng tâm review cho PR nặng AI
|
||||
|
||||
- Tương thích hợp đồng.
|
||||
- Ranh giới bảo mật.
|
||||
- Xử lý lỗi và hành vi fallback.
|
||||
- Hồi quy hiệu suất và bộ nhớ.
|
||||
|
||||
---
|
||||
|
||||
## 8. SLA review và kỷ luật hàng đợi
|
||||
|
||||
- Mục tiêu triage maintainer đầu tiên: trong vòng 48 giờ.
|
||||
- Nếu PR bị chặn, maintainer để lại một checklist hành động được.
|
||||
- Tự động hóa `stale` được dùng để giữ hàng đợi lành mạnh; maintainer có thể áp dụng `no-stale` khi cần.
|
||||
- Tự động hóa `pr-hygiene` kiểm tra các PR mở mỗi 12 giờ và đăng nhắc nhở khi PR không có commit mới trong 48+ giờ và rơi vào một trong hai trường hợp: đang tụt hậu so với `master` hoặc thiếu/thất bại `CI Required Gate` trên head commit.
|
||||
|
||||
### 8.1 Kiểm soát ngân sách hàng đợi
|
||||
|
||||
- Sử dụng ngân sách hàng đợi review: giới hạn số PR đang được review sâu đồng thời mỗi maintainer và giữ phần còn lại ở trạng thái triage.
|
||||
- Đối với công việc stacked, yêu cầu `Depends on #...` rõ ràng để thứ tự review mang tính quyết định.
|
||||
|
||||
### 8.2 Kiểm soát áp lực backlog
|
||||
|
||||
- Nếu một PR mới thay thế một PR cũ đang mở, yêu cầu `Supersedes #...` và đóng PR cũ sau khi maintainer xác nhận.
|
||||
- Đánh dấu các PR ngủ đông/dư thừa bằng `stale-candidate` hoặc `superseded` để giảm nỗ lực review trùng lặp.
|
||||
|
||||
### 8.3 Kỷ luật triage issue
|
||||
|
||||
- `r:needs-repro` cho báo cáo lỗi chưa đầy đủ (yêu cầu repro mang tính quyết định trước khi triage sâu).
|
||||
- `r:support` cho các mục sử dụng/trợ giúp nên xử lý ngoài bug backlog.
|
||||
- Nhãn `invalid` / `duplicate` kích hoạt tự động hóa đóng **chỉ issue** kèm hướng dẫn.
|
||||
|
||||
### 8.4 Bảo vệ tác dụng phụ của tự động hóa
|
||||
|
||||
- `PR Auto Responder` loại bỏ trùng lặp comment dựa trên nhãn để tránh spam.
|
||||
- Các luồng đóng tự động chỉ giới hạn cho issue, không phải PR.
|
||||
- Maintainer có thể đóng băng tính toán lại rủi ro tự động bằng `risk: manual` khi ngữ cảnh yêu cầu ghi đè thủ công.
|
||||
|
||||
---
|
||||
|
||||
## 9. Quy tắc bảo mật và ổn định
|
||||
|
||||
Các thay đổi ở những khu vực này yêu cầu review chặt chẽ hơn và bằng chứng test mạnh hơn:
|
||||
|
||||
- `src/security/**`
|
||||
- Quản lý tiến trình runtime.
|
||||
- Hành vi ingress/xác thực gateway (`src/gateway/**`).
|
||||
- Ranh giới truy cập filesystem.
|
||||
- Hành vi mạng/xác thực.
|
||||
- GitHub workflow và pipeline release.
|
||||
- Các tool có khả năng thực thi (`src/tools/**`).
|
||||
|
||||
### 9.1 Tối thiểu cho PR rủi ro
|
||||
|
||||
- Tuyên bố mối đe dọa/rủi ro.
|
||||
- Ghi chú biện pháp giảm thiểu.
|
||||
- Các bước rollback.
|
||||
|
||||
### 9.2 Khuyến nghị cho PR rủi ro cao
|
||||
|
||||
- Bao gồm một test tập trung chứng minh hành vi ranh giới.
|
||||
- Bao gồm một kịch bản failure mode rõ ràng và sự suy giảm mong đợi.
|
||||
|
||||
Đối với các đóng góp có hỗ trợ agent, reviewer cũng nên xác minh rằng tác giả hiểu hành vi runtime và blast radius.
|
||||
|
||||
---
|
||||
|
||||
## 10. Giao thức phục hồi sự cố
|
||||
|
||||
Nếu một PR đã merge gây ra hồi quy:
|
||||
|
||||
1. Revert PR ngay lập tức trên `master`.
|
||||
2. Mở issue theo dõi với phân tích nguyên nhân gốc.
|
||||
3. Chỉ đưa lại bản sửa lỗi khi có test hồi quy.
|
||||
|
||||
Ưu tiên khôi phục nhanh chất lượng dịch vụ hơn là bản vá hoàn hảo nhưng chậm trễ.
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist merge của maintainer
|
||||
|
||||
- Phạm vi tập trung và dễ hiểu.
|
||||
- CI gate đã xanh.
|
||||
- Kiểm tra chất lượng tài liệu đã xanh khi tài liệu thay đổi.
|
||||
- Các trường tác động bảo mật đã hoàn thành.
|
||||
- Các trường tính riêng tư/vệ sinh dữ liệu đã hoàn thành và bằng chứng đã được biên tập/ẩn danh.
|
||||
- Ghi chú workflow agent đủ để tái tạo (nếu tự động hóa được sử dụng).
|
||||
- Kế hoạch rollback rõ ràng.
|
||||
- Tiêu đề commit theo Conventional Commits.
|
||||
|
||||
---
|
||||
|
||||
## 12. Mô hình vận hành review agent
|
||||
|
||||
Để giữ chất lượng review ổn định khi khối lượng PR cao, sử dụng mô hình review hai làn.
|
||||
|
||||
### 12.1 Làn A: triage nhanh (thân thiện với agent)
|
||||
|
||||
- Xác nhận độ đầy đủ của template PR.
|
||||
- Xác nhận tín hiệu CI gate (`CI Required Gate`).
|
||||
- Xác nhận phân loại rủi ro qua nhãn và các đường dẫn đã chạm.
|
||||
- Xác nhận tuyên bố rollback tồn tại.
|
||||
- Xác nhận phần tính riêng tư/vệ sinh dữ liệu và các yêu cầu diễn đạt trung lập đã được thỏa mãn.
|
||||
- Xác nhận bất kỳ ngôn ngữ giống danh tính nào đều sử dụng thuật ngữ gốc ZeroClaw/dự án.
|
||||
|
||||
### 12.2 Làn B: review sâu (dựa trên rủi ro)
|
||||
|
||||
Bắt buộc cho các thay đổi rủi ro cao (security/runtime/gateway/CI):
|
||||
|
||||
- Xác thực giả định mô hình mối đe dọa.
|
||||
- Xác thực hành vi failure mode và suy giảm.
|
||||
- Xác thực tương thích ngược và tác động migration.
|
||||
- Xác thực tác động observability/logging.
|
||||
|
||||
---
|
||||
|
||||
## 13. Ưu tiên hàng đợi và kỷ luật nhãn
|
||||
|
||||
### 13.1 Khuyến nghị thứ tự triage
|
||||
|
||||
1. `size: XS`/`size: S` + sửa lỗi/bảo mật.
|
||||
2. `size: M` thay đổi tập trung.
|
||||
3. `size: L`/`size: XL` yêu cầu chia nhỏ hoặc review theo giai đoạn.
|
||||
|
||||
### 13.2 Kỷ luật nhãn
|
||||
|
||||
- Nhãn đường dẫn xác định quyền sở hữu hệ thống con nhanh chóng.
|
||||
- Nhãn kích thước điều hướng chiến lược lô.
|
||||
- Nhãn rủi ro điều hướng độ sâu review (`risk: low/medium/high`).
|
||||
- Nhãn module (`<module>: <component>`) cải thiện phân tuyến reviewer cho các thay đổi cụ thể theo integration và các module mới được thêm vào trong tương lai.
|
||||
- `risk: manual` cho phép maintainer bảo tồn phán đoán rủi ro của con người khi tự động hóa thiếu ngữ cảnh.
|
||||
- `no-stale` được dành riêng cho công việc đã được chấp nhận nhưng bị chặn.
|
||||
|
||||
---
|
||||
|
||||
## 14. Hợp đồng bàn giao agent
|
||||
|
||||
Khi một agent bàn giao cho agent khác (hoặc cho maintainer), bao gồm:
|
||||
|
||||
1. Ranh giới phạm vi (những gì đã thay đổi / những gì không thay đổi).
|
||||
2. Bằng chứng validation.
|
||||
3. Rủi ro mở và những điều chưa biết.
|
||||
4. Hành động tiếp theo được đề xuất.
|
||||
|
||||
Điều này giữ cho tổn thất ngữ cảnh ở mức thấp và tránh việc phải đào sâu lặp lại.
|
||||
|
||||
---
|
||||
|
||||
## 15. Tài liệu liên quan
|
||||
|
||||
- [README.md](README.md) — phân loại và điều hướng tài liệu.
|
||||
- [ci-map.md](ci-map.md) — bản đồ quyền sở hữu và triage CI workflow.
|
||||
- [reviewer-playbook.md](reviewer-playbook.md) — mô hình thực thi của reviewer.
|
||||
- [actions-source-policy.md](actions-source-policy.md) — chính sách allowlist nguồn action.
|
||||
|
||||
---
|
||||
|
||||
## 16. Ghi chú bảo trì
|
||||
|
||||
- **Chủ sở hữu:** các maintainer chịu trách nhiệm về quản trị cộng tác và chất lượng merge.
|
||||
- **Kích hoạt cập nhật:** thay đổi branch protection, thay đổi chính sách nhãn/rủi ro, cập nhật quản trị hàng đợi hoặc thay đổi quy trình review agent.
|
||||
- **Lần review cuối:** 2026-02-18.
|
||||
@@ -1,17 +0,0 @@
|
||||
# Tài liệu snapshot và triage dự án
|
||||
|
||||
Snapshot trạng thái dự án có giới hạn thời gian cho tài liệu lập kế hoạch và công việc vận hành.
|
||||
|
||||
## Snapshot hiện tại
|
||||
|
||||
- [project-triage-snapshot-2026-02-18.md](../../../maintainers/project-triage-snapshot-2026-02-18.md)
|
||||
|
||||
## Phạm vi
|
||||
|
||||
Snapshot dự án là các đánh giá có giới hạn thời gian về PR mở, issue và tình trạng tài liệu. Dùng chúng để:
|
||||
|
||||
- Xác định các khoảng trống tài liệu được thúc đẩy bởi công việc tính năng
|
||||
- Ưu tiên bảo trì tài liệu song song với thay đổi code
|
||||
- Theo dõi áp lực PR/issue đang phát triển theo thời gian
|
||||
|
||||
Để phân loại tài liệu ổn định (không giới hạn thời gian), dùng [docs-inventory.md](../../../maintainers/docs-inventory.md).
|
||||
@@ -1,253 +0,0 @@
|
||||
# Tài liệu tham khảo Providers — ZeroClaw
|
||||
|
||||
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-03-10**.
|
||||
|
||||
## Cách liệt kê các Provider
|
||||
|
||||
```bash
|
||||
zeroclaw providers
|
||||
```
|
||||
|
||||
## Thứ tự ưu tiên khi giải quyết thông tin xác thực
|
||||
|
||||
Thứ tự ưu tiên tại runtime:
|
||||
|
||||
1. Thông tin xác thực tường minh từ config/CLI
|
||||
2. Biến môi trường dành riêng cho provider
|
||||
3. Biến môi trường dự phòng chung: `ZEROCLAW_API_KEY`, sau đó là `API_KEY`
|
||||
|
||||
Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi provider dự phòng tự giải quyết thông tin xác thực của mình độc lập. Key xác thực của provider chính không tự động dùng cho provider dự phòng.
|
||||
|
||||
## Danh mục Provider
|
||||
|
||||
| Canonical ID | Alias | Cục bộ | Biến môi trường dành riêng |
|
||||
|---|---|---:|---|
|
||||
| `openrouter` | — | Không | `OPENROUTER_API_KEY` |
|
||||
| `anthropic` | — | Không | `ANTHROPIC_OAUTH_TOKEN`, `ANTHROPIC_API_KEY` |
|
||||
| `openai` | — | Không | `OPENAI_API_KEY` |
|
||||
| `ollama` | — | Có | `OLLAMA_API_KEY` (tùy chọn) |
|
||||
| `gemini` | `google`, `google-gemini` | Không | `GEMINI_API_KEY`, `GOOGLE_API_KEY` |
|
||||
| `venice` | — | Không | `VENICE_API_KEY` |
|
||||
| `vercel` | `vercel-ai` | Không | `VERCEL_API_KEY` |
|
||||
| `cloudflare` | `cloudflare-ai` | Không | `CLOUDFLARE_API_KEY` |
|
||||
| `moonshot` | `kimi` | Không | `MOONSHOT_API_KEY` |
|
||||
| `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` |
|
||||
| `bedrock` | `aws-bedrock` | Không | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (tùy chọn: `AWS_REGION`) |
|
||||
| `qianfan` | `baidu` | Không | `QIANFAN_API_KEY` |
|
||||
| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us`, `qwen-code`, `qwen-oauth`, `qwen_oauth` | Không | `QWEN_OAUTH_TOKEN`, `DASHSCOPE_API_KEY` |
|
||||
| `groq` | — | Không | `GROQ_API_KEY` |
|
||||
| `mistral` | — | Không | `MISTRAL_API_KEY` |
|
||||
| `xai` | `grok` | Không | `XAI_API_KEY` |
|
||||
| `deepseek` | — | Không | `DEEPSEEK_API_KEY` |
|
||||
| `together` | `together-ai` | Không | `TOGETHER_API_KEY` |
|
||||
| `fireworks` | `fireworks-ai` | Không | `FIREWORKS_API_KEY` |
|
||||
| `perplexity` | — | Không | `PERPLEXITY_API_KEY` |
|
||||
| `cohere` | — | Không | `COHERE_API_KEY` |
|
||||
| `copilot` | `github-copilot` | Không | (dùng config/`API_KEY` fallback với GitHub token) |
|
||||
| `lmstudio` | `lm-studio` | Có | (tùy chọn; mặc định là cục bộ) |
|
||||
| `nvidia` | `nvidia-nim`, `build.nvidia.com` | Không | `NVIDIA_API_KEY` |
|
||||
|
||||
### Ghi chú về Gemini
|
||||
|
||||
- Provider ID: `gemini` (alias: `google`, `google-gemini`)
|
||||
- Xác thực có thể dùng `GEMINI_API_KEY`, `GOOGLE_API_KEY`, hoặc Gemini CLI OAuth cache (`~/.gemini/oauth_creds.json`)
|
||||
- Request bằng API key dùng endpoint `generativelanguage.googleapis.com/v1beta`
|
||||
- Request OAuth qua Gemini CLI dùng endpoint `cloudcode-pa.googleapis.com/v1internal` theo chuẩn Code Assist request envelope
|
||||
|
||||
### Ghi chú về Ollama Vision
|
||||
|
||||
- Provider ID: `ollama`
|
||||
- Hỗ trợ đầu vào hình ảnh qua marker nội tuyến trong tin nhắn: ``[IMAGE:<source>]``
|
||||
- Sau khi chuẩn hóa multimodal, ZeroClaw gửi payload hình ảnh qua trường `messages[].images` gốc của Ollama.
|
||||
- Nếu chọn provider không hỗ trợ vision, ZeroClaw trả về lỗi rõ ràng thay vì âm thầm bỏ qua hình ảnh.
|
||||
|
||||
### Ghi chú về Bedrock
|
||||
|
||||
- Provider ID: `bedrock` (alias: `aws-bedrock`)
|
||||
- API: [Converse API](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html)
|
||||
- Xác thực: AWS AKSK (không phải một API key đơn lẻ). Cần đặt biến môi trường `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY`.
|
||||
- Tùy chọn: `AWS_SESSION_TOKEN` cho thông tin xác thực tạm thời/STS, `AWS_REGION` hoặc `AWS_DEFAULT_REGION` (mặc định: `us-east-1`).
|
||||
- Model mặc định khi khởi tạo: `anthropic.claude-sonnet-4-5-20250929-v1:0`
|
||||
- Hỗ trợ native tool calling và prompt caching (`cachePoint`).
|
||||
- Hỗ trợ cross-region inference profiles (ví dụ: `us.anthropic.claude-*`).
|
||||
- Model ID dùng định dạng Bedrock: `anthropic.claude-sonnet-4-6`, `anthropic.claude-opus-4-6-v1`, v.v.
|
||||
|
||||
### Bật/tắt tính năng Reasoning của Ollama
|
||||
|
||||
Bạn có thể kiểm soát hành vi reasoning/thinking của Ollama từ `config.toml`:
|
||||
|
||||
```toml
|
||||
[runtime]
|
||||
reasoning_enabled = false
|
||||
```
|
||||
|
||||
Hành vi:
|
||||
|
||||
- `false`: gửi `think: false` đến các yêu cầu Ollama `/api/chat`.
|
||||
- `true`: gửi `think: true`.
|
||||
- Không đặt: bỏ qua `think` và giữ nguyên mặc định của Ollama/model.
|
||||
|
||||
### Ghi chú về Kimi Code
|
||||
|
||||
- Provider ID: `kimi-code`
|
||||
- Endpoint: `https://api.kimi.com/coding/v1`
|
||||
- Model mặc định khi khởi tạo: `kimi-for-coding` (thay thế: `kimi-k2.5`)
|
||||
- Runtime tự động thêm `User-Agent: KimiCLI/0.77` để đảm bảo tương thích.
|
||||
|
||||
### Ghi chú về NVIDIA NIM
|
||||
|
||||
- Canonical provider ID: `nvidia`
|
||||
- Alias: `nvidia-nim`, `build.nvidia.com`
|
||||
- Base API URL: `https://integrate.api.nvidia.com/v1`
|
||||
- Khám phá model: `zeroclaw models refresh --provider nvidia`
|
||||
|
||||
Các model ID khởi đầu được khuyến nghị (đã xác minh với danh mục NVIDIA API ngày 2026-02-18):
|
||||
|
||||
- `meta/llama-3.3-70b-instruct`
|
||||
- `deepseek-ai/deepseek-v3.2`
|
||||
- `nvidia/llama-3.3-nemotron-super-49b-v1.5`
|
||||
- `nvidia/llama-3.1-nemotron-ultra-253b-v1`
|
||||
|
||||
## Endpoint Tùy chỉnh
|
||||
|
||||
- Endpoint tương thích OpenAI:
|
||||
|
||||
```toml
|
||||
default_provider = "custom:https://your-api.example.com"
|
||||
```
|
||||
|
||||
- Endpoint tương thích Anthropic:
|
||||
|
||||
```toml
|
||||
default_provider = "anthropic-custom:https://your-api.example.com"
|
||||
```
|
||||
|
||||
## Cấu hình MiniMax OAuth (`config.toml`)
|
||||
|
||||
Đặt provider MiniMax và OAuth placeholder trong config:
|
||||
|
||||
```toml
|
||||
default_provider = "minimax-oauth"
|
||||
api_key = "minimax-oauth"
|
||||
```
|
||||
|
||||
Sau đó cung cấp một trong các thông tin xác thực sau qua biến môi trường:
|
||||
|
||||
- `MINIMAX_OAUTH_TOKEN` (ưu tiên, access token trực tiếp)
|
||||
- `MINIMAX_API_KEY` (token tĩnh/cũ)
|
||||
- `MINIMAX_OAUTH_REFRESH_TOKEN` (tự động làm mới access token khi khởi động)
|
||||
|
||||
Tùy chọn:
|
||||
|
||||
- `MINIMAX_OAUTH_REGION=global` hoặc `cn` (mặc định theo alias của provider)
|
||||
- `MINIMAX_OAUTH_CLIENT_ID` để ghi đè OAuth client id mặc định
|
||||
|
||||
Lưu ý về tương thích channel:
|
||||
|
||||
- Đối với các cuộc trò chuyện channel được hỗ trợ bởi MiniMax, lịch sử runtime được chuẩn hóa để duy trì thứ tự lượt hợp lệ `user`/`assistant`.
|
||||
- Hướng dẫn phân phối đặc thù của channel (ví dụ: marker đính kèm Telegram) được hợp nhất vào system prompt đầu tiên thay vì được thêm vào như một lượt `system` cuối cùng.
|
||||
|
||||
## Cấu hình Qwen Code OAuth (`config.toml`)
|
||||
|
||||
Đặt chế độ Qwen Code OAuth trong config:
|
||||
|
||||
```toml
|
||||
default_provider = "qwen-code"
|
||||
api_key = "qwen-oauth"
|
||||
```
|
||||
|
||||
Thứ tự ưu tiên giải quyết thông tin xác thực cho `qwen-code`:
|
||||
|
||||
1. Giá trị `api_key` tường minh (nếu không phải placeholder `qwen-oauth`)
|
||||
2. `QWEN_OAUTH_TOKEN`
|
||||
3. `~/.qwen/oauth_creds.json` (tái sử dụng thông tin xác thực OAuth đã cache của Qwen Code)
|
||||
4. Tùy chọn làm mới qua `QWEN_OAUTH_REFRESH_TOKEN` (hoặc refresh token đã cache)
|
||||
5. Nếu không dùng OAuth placeholder, `DASHSCOPE_API_KEY` vẫn có thể được dùng làm dự phòng
|
||||
|
||||
Tùy chọn ghi đè endpoint:
|
||||
|
||||
- `QWEN_OAUTH_RESOURCE_URL` (được chuẩn hóa thành `https://.../v1` nếu cần)
|
||||
- Nếu không đặt, `resource_url` từ thông tin xác thực OAuth đã cache sẽ được dùng khi có
|
||||
|
||||
## Định tuyến Model (`hint:<name>`)
|
||||
|
||||
Bạn có thể định tuyến các lời gọi model theo hint bằng cách sử dụng `[[model_routes]]`:
|
||||
|
||||
```toml
|
||||
[[model_routes]]
|
||||
hint = "reasoning"
|
||||
provider = "openrouter"
|
||||
model = "anthropic/claude-opus-4-20250514"
|
||||
|
||||
[[model_routes]]
|
||||
hint = "fast"
|
||||
provider = "groq"
|
||||
model = "llama-3.3-70b-versatile"
|
||||
```
|
||||
|
||||
Sau đó gọi với tên model hint (ví dụ từ tool hoặc các đường dẫn tích hợp):
|
||||
|
||||
```text
|
||||
hint:reasoning
|
||||
```
|
||||
|
||||
## Định tuyến Embedding (`hint:<name>`)
|
||||
|
||||
Bạn có thể định tuyến các lời gọi embedding theo cùng mẫu hint bằng `[[embedding_routes]]`.
|
||||
Đặt `[memory].embedding_model` thành giá trị `hint:<name>` để kích hoạt định tuyến.
|
||||
|
||||
```toml
|
||||
[memory]
|
||||
embedding_model = "hint:semantic"
|
||||
|
||||
[[embedding_routes]]
|
||||
hint = "semantic"
|
||||
provider = "openai"
|
||||
model = "text-embedding-3-small"
|
||||
dimensions = 1536
|
||||
|
||||
[[embedding_routes]]
|
||||
hint = "archive"
|
||||
provider = "custom:https://embed.example.com/v1"
|
||||
model = "your-embedding-model-id"
|
||||
dimensions = 1024
|
||||
```
|
||||
|
||||
Các embedding provider được hỗ trợ:
|
||||
|
||||
- `none`
|
||||
- `openai`
|
||||
- `custom:<url>` (endpoint embeddings tương thích OpenAI)
|
||||
|
||||
Tùy chọn ghi đè key theo từng route:
|
||||
|
||||
```toml
|
||||
[[embedding_routes]]
|
||||
hint = "semantic"
|
||||
provider = "openai"
|
||||
model = "text-embedding-3-small"
|
||||
api_key = "sk-route-specific"
|
||||
```
|
||||
|
||||
## Nâng cấp Model An toàn
|
||||
|
||||
Sử dụng các hint ổn định và chỉ cập nhật target route khi provider ngừng hỗ trợ model ID cũ.
|
||||
|
||||
Quy trình được khuyến nghị:
|
||||
|
||||
1. Giữ nguyên các call site (`hint:reasoning`, `hint:semantic`).
|
||||
2. Chỉ thay đổi model đích trong `[[model_routes]]` hoặc `[[embedding_routes]]`.
|
||||
3. Chạy:
|
||||
- `zeroclaw doctor`
|
||||
- `zeroclaw status`
|
||||
4. Smoke test một luồng đại diện (chat + memory retrieval) trước khi triển khai.
|
||||
|
||||
Cách này giảm thiểu rủi ro phá vỡ vì các tích hợp và prompt không cần thay đổi khi nâng cấp model ID.
|
||||
@@ -1,229 +0,0 @@
|
||||
# Playbook Proxy Agent
|
||||
|
||||
Tài liệu này cung cấp các tool call có thể copy-paste để cấu hình hành vi proxy qua `proxy_config`.
|
||||
|
||||
Dùng tài liệu này khi bạn muốn agent chuyển đổi phạm vi proxy nhanh chóng và an toàn.
|
||||
|
||||
## 0. Tóm Tắt
|
||||
|
||||
- **Mục đích:** cung cấp tool call sẵn sàng sử dụng để quản lý phạm vi proxy và rollback.
|
||||
- **Đối tượng:** operator và maintainer đang chạy ZeroClaw trong mạng có proxy.
|
||||
- **Phạm vi:** các hành động `proxy_config`, lựa chọn mode, quy trình xác minh và xử lý sự cố.
|
||||
- **Ngoài phạm vi:** gỡ lỗi mạng chung không liên quan đến hành vi runtime của ZeroClaw.
|
||||
|
||||
---
|
||||
|
||||
## 1. Đường Dẫn Nhanh Theo Mục Đích
|
||||
|
||||
Dùng mục này để định tuyến vận hành nhanh.
|
||||
|
||||
### 1.1 Chỉ proxy traffic nội bộ ZeroClaw
|
||||
|
||||
1. Dùng scope `zeroclaw`.
|
||||
2. Đặt `http_proxy`/`https_proxy` hoặc `all_proxy`.
|
||||
3. Xác minh bằng `{"action":"get"}`.
|
||||
|
||||
Xem:
|
||||
|
||||
- [Mục 4](#4-mode-a--chỉ-proxy-cho-nội-bộ-zeroclaw)
|
||||
|
||||
### 1.2 Chỉ proxy các dịch vụ được chọn
|
||||
|
||||
1. Dùng scope `services`.
|
||||
2. Đặt các key cụ thể hoặc wildcard selector trong `services`.
|
||||
3. Xác minh phủ sóng bằng `{"action":"list_services"}`.
|
||||
|
||||
Xem:
|
||||
|
||||
- [Mục 5](#5-mode-b--chỉ-proxy-cho-các-dịch-vụ-cụ-thể)
|
||||
|
||||
### 1.3 Xuất biến môi trường proxy cho toàn bộ process
|
||||
|
||||
1. Dùng scope `environment`.
|
||||
2. Áp dụng bằng `{"action":"apply_env"}`.
|
||||
3. Xác minh snapshot env qua `{"action":"get"}`.
|
||||
|
||||
Xem:
|
||||
|
||||
- [Mục 6](#6-mode-c--proxy-cho-toàn-bộ-môi-trường-process)
|
||||
|
||||
### 1.4 Rollback khẩn cấp
|
||||
|
||||
1. Tắt proxy.
|
||||
2. Nếu cần, xóa các biến env đã xuất.
|
||||
3. Kiểm tra lại snapshot runtime và môi trường.
|
||||
|
||||
Xem:
|
||||
|
||||
- [Mục 7](#7-các-mẫu-tắt--rollback)
|
||||
|
||||
---
|
||||
|
||||
## 2. Ma Trận Quyết Định Phạm Vi
|
||||
|
||||
| Phạm vi | Ảnh hưởng | Xuất biến env | Trường hợp dùng điển hình |
|
||||
|---|---|---|---|
|
||||
| `zeroclaw` | Các HTTP client nội bộ ZeroClaw | Không | Proxying runtime thông thường không có tác dụng phụ cấp process |
|
||||
| `services` | Chỉ các service key/selector được chọn | Không | Định tuyến chi tiết cho provider/tool/channel cụ thể |
|
||||
| `environment` | Runtime + biến môi trường proxy của process | Có | Các tích hợp yêu cầu `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Quy Trình An Toàn Chuẩn
|
||||
|
||||
Dùng trình tự này cho mọi thay đổi proxy:
|
||||
|
||||
1. Kiểm tra trạng thái hiện tại.
|
||||
2. Khám phá các service key/selector hợp lệ.
|
||||
3. Áp dụng cấu hình phạm vi mục tiêu.
|
||||
4. Xác minh snapshot runtime và môi trường.
|
||||
5. Rollback nếu hành vi không như kỳ vọng.
|
||||
|
||||
Tool call:
|
||||
|
||||
```json
|
||||
{"action":"get"}
|
||||
{"action":"list_services"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Mode A — Chỉ Proxy Cho Nội Bộ ZeroClaw
|
||||
|
||||
Dùng khi traffic HTTP của provider/channel/tool ZeroClaw cần đi qua proxy mà không xuất biến env proxy cấp process.
|
||||
|
||||
Tool call:
|
||||
|
||||
```json
|
||||
{"action":"set","enabled":true,"scope":"zeroclaw","http_proxy":"http://127.0.0.1:7890","https_proxy":"http://127.0.0.1:7890","no_proxy":["localhost","127.0.0.1"]}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
Hành vi kỳ vọng:
|
||||
|
||||
- Runtime proxy hoạt động cho các HTTP client của ZeroClaw.
|
||||
- Không cần xuất `HTTP_PROXY` / `HTTPS_PROXY` vào env của process.
|
||||
|
||||
---
|
||||
|
||||
## 5. Mode B — Chỉ Proxy Cho Các Dịch Vụ Cụ Thể
|
||||
|
||||
Dùng khi chỉ một phần hệ thống cần đi qua proxy (ví dụ provider/tool/channel cụ thể).
|
||||
|
||||
### 5.1 Nhắm vào dịch vụ cụ thể
|
||||
|
||||
```json
|
||||
{"action":"set","enabled":true,"scope":"services","services":["provider.openai","tool.http_request","channel.telegram"],"all_proxy":"socks5h://127.0.0.1:1080","no_proxy":["localhost","127.0.0.1",".internal"]}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
### 5.2 Nhắm theo selector
|
||||
|
||||
```json
|
||||
{"action":"set","enabled":true,"scope":"services","services":["provider.*","tool.*"],"http_proxy":"http://127.0.0.1:7890"}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
Hành vi kỳ vọng:
|
||||
|
||||
- Chỉ các service khớp mới dùng proxy.
|
||||
- Các service không khớp bỏ qua proxy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mode C — Proxy Cho Toàn Bộ Môi Trường Process
|
||||
|
||||
Dùng khi bạn cần xuất tường minh các biến env của process (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) cho các tích hợp runtime.
|
||||
|
||||
### 6.1 Cấu hình và áp dụng environment scope
|
||||
|
||||
```json
|
||||
{"action":"set","enabled":true,"scope":"environment","http_proxy":"http://127.0.0.1:7890","https_proxy":"http://127.0.0.1:7890","no_proxy":"localhost,127.0.0.1,.internal"}
|
||||
{"action":"apply_env"}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
Hành vi kỳ vọng:
|
||||
|
||||
- Runtime proxy hoạt động.
|
||||
- Các biến môi trường được xuất cho process.
|
||||
|
||||
---
|
||||
|
||||
## 7. Các Mẫu Tắt / Rollback
|
||||
|
||||
### 7.1 Tắt proxy (hành vi an toàn mặc định)
|
||||
|
||||
```json
|
||||
{"action":"disable"}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
### 7.2 Tắt proxy và xóa cưỡng bức các biến env
|
||||
|
||||
```json
|
||||
{"action":"disable","clear_env":true}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
### 7.3 Giữ proxy bật nhưng chỉ xóa các biến env đã xuất
|
||||
|
||||
```json
|
||||
{"action":"clear_env"}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Các Công Thức Vận Hành Thường Dùng
|
||||
|
||||
### 8.1 Chuyển từ proxy toàn environment sang proxy chỉ service
|
||||
|
||||
```json
|
||||
{"action":"set","enabled":true,"scope":"services","services":["provider.openai","tool.http_request"],"all_proxy":"socks5://127.0.0.1:1080"}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
### 8.2 Thêm một dịch vụ proxied
|
||||
|
||||
```json
|
||||
{"action":"set","scope":"services","services":["provider.openai","tool.http_request","channel.slack"]}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
### 8.3 Đặt lại danh sách `services` với selector
|
||||
|
||||
```json
|
||||
{"action":"set","scope":"services","services":["provider.*","channel.telegram"]}
|
||||
{"action":"get"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Xử Lý Sự Cố
|
||||
|
||||
- Lỗi: `proxy.scope='services' requires a non-empty proxy.services list`
|
||||
- Khắc phục: đặt ít nhất một service key cụ thể hoặc selector.
|
||||
|
||||
- Lỗi: invalid proxy URL scheme
|
||||
- Scheme được chấp nhận: `http`, `https`, `socks5`, `socks5h`.
|
||||
|
||||
- Proxy không áp dụng như kỳ vọng
|
||||
- Chạy `{"action":"list_services"}` và xác minh tên/selector dịch vụ.
|
||||
- Chạy `{"action":"get"}` và kiểm tra giá trị snapshot `runtime_proxy` và `environment`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Tài Liệu Liên Quan
|
||||
|
||||
- [README.md](./README.md) — Chỉ mục tài liệu và phân loại.
|
||||
- [network-deployment.md](network-deployment.md) — Hướng dẫn triển khai mạng đầu-cuối và topology tunnel.
|
||||
- [resource-limits.md](./resource-limits.md) — Giới hạn an toàn runtime cho ngữ cảnh thực thi mạng/tool.
|
||||
|
||||
---
|
||||
|
||||
## 11. Ghi Chú Bảo Trì
|
||||
|
||||
- **Chủ sở hữu:** maintainer runtime và tooling.
|
||||
- **Điều kiện cập nhật:** các hành động `proxy_config` mới, ngữ nghĩa phạm vi proxy, hoặc thay đổi selector dịch vụ được hỗ trợ.
|
||||
- **Xem xét lần cuối:** 2026-02-18.
|
||||
@@ -1,22 +0,0 @@
|
||||
# Danh mục tham chiếu
|
||||
|
||||
Tra cứu lệnh, provider, channel, config và tích hợp.
|
||||
|
||||
## Tham chiếu cốt lõi
|
||||
|
||||
- Lệnh theo workflow: [../commands-reference.md](../commands-reference.md)
|
||||
- ID provider / alias / biến môi trường: [../providers-reference.md](../providers-reference.md)
|
||||
- Thiết lập channel + allowlist: [../channels-reference.md](../channels-reference.md)
|
||||
- Giá trị mặc định và khóa config: [../config-reference.md](../config-reference.md)
|
||||
|
||||
## Mở rộng provider và tích hợp
|
||||
|
||||
- Endpoint provider tùy chỉnh: [../custom-providers.md](../custom-providers.md)
|
||||
- Tích hợp provider Z.AI / GLM: [../zai-glm-setup.md](../zai-glm-setup.md)
|
||||
- Các mẫu tích hợp dựa trên LangGraph: [../langgraph-integration.md](../langgraph-integration.md)
|
||||
|
||||
## Cách dùng
|
||||
|
||||
Sử dụng bộ sưu tập này khi bạn cần chi tiết CLI/config chính xác hoặc các mẫu tích hợp provider thay vì hướng dẫn từng bước.
|
||||
|
||||
Khi thêm tài liệu tham chiếu/tích hợp mới, hãy đảm bảo nó được liên kết trong cả [../SUMMARY.md](../SUMMARY.md) và [docs-inventory.md](../../../maintainers/docs-inventory.md).
|
||||
@@ -1,133 +0,0 @@
|
||||
# Quy trình Release ZeroClaw
|
||||
|
||||
Runbook này định nghĩa quy trình release tiêu chuẩn của maintainer.
|
||||
|
||||
Cập nhật lần cuối: **2026-02-20**.
|
||||
|
||||
## Mục tiêu release
|
||||
|
||||
- Đảm bảo release có thể dự đoán và lặp lại.
|
||||
- Chỉ publish từ code đã có trên `master`.
|
||||
- Xác minh các artifact đa nền tảng trước khi publish.
|
||||
- Duy trì nhịp release đều đặn ngay cả khi PR volume cao.
|
||||
|
||||
## Chu kỳ tiêu chuẩn
|
||||
|
||||
- Release patch/minor: hàng tuần hoặc hai tuần một lần.
|
||||
- Bản vá bảo mật khẩn cấp: out-of-band.
|
||||
- Không bao giờ chờ tích lũy quá nhiều commit lớn.
|
||||
|
||||
## Hợp đồng workflow
|
||||
|
||||
Automation release nằm tại:
|
||||
|
||||
- `.github/workflows/pub-release.yml`
|
||||
- `.github/workflows/pub-homebrew-core.yml` (PR formula Homebrew thủ công, do bot sở hữu)
|
||||
|
||||
Các chế độ:
|
||||
|
||||
- Tag push `v*`: chế độ publish.
|
||||
- Manual dispatch: chế độ chỉ xác minh hoặc publish.
|
||||
- Lịch hàng tuần: chế độ chỉ xác minh.
|
||||
|
||||
Các guardrail ở chế độ publish:
|
||||
|
||||
- Tag phải khớp định dạng semver-like `vX.Y.Z[-suffix]`.
|
||||
- Tag phải đã tồn tại trên origin.
|
||||
- Commit của tag phải có thể truy vết được từ `origin/master`.
|
||||
- GHCR image tag tương ứng (`ghcr.io/<owner>/<repo>:<tag>`) phải sẵn sàng trước khi GitHub Release publish hoàn tất.
|
||||
- Artifact được xác minh trước khi publish.
|
||||
|
||||
## Quy trình maintainer
|
||||
|
||||
### 1) Preflight trên `master`
|
||||
|
||||
1. Đảm bảo các required check đều xanh trên `master` mới nhất.
|
||||
2. Xác nhận không có sự cố ưu tiên cao hoặc regression đã biết nào đang mở.
|
||||
3. Xác nhận các workflow installer và Docker đều khoẻ mạnh trên các commit `master` gần đây.
|
||||
|
||||
### 2) Chạy verification build (không publish)
|
||||
|
||||
Chạy `Pub Release` thủ công:
|
||||
|
||||
- `publish_release`: `false`
|
||||
- `release_ref`: `master`
|
||||
|
||||
Kết quả mong đợi:
|
||||
|
||||
- Ma trận target đầy đủ build thành công.
|
||||
- `verify-artifacts` xác nhận tất cả archive mong đợi đều tồn tại.
|
||||
- Không có GitHub Release nào được publish.
|
||||
|
||||
### 3) Cut release tag
|
||||
|
||||
Từ một checkout cục bộ sạch đã sync với `origin/master`:
|
||||
|
||||
```bash
|
||||
scripts/release/cut_release_tag.sh vX.Y.Z --push
|
||||
```
|
||||
|
||||
Script này đảm bảo:
|
||||
|
||||
- working tree sạch
|
||||
- `HEAD == origin/master`
|
||||
- tag không bị trùng lặp
|
||||
- định dạng tag semver-like
|
||||
|
||||
### 4) Theo dõi publish run
|
||||
|
||||
Sau khi push tag, theo dõi:
|
||||
|
||||
1. Chế độ publish `Pub Release`
|
||||
2. Job publish `Pub Docker Img`
|
||||
|
||||
Kết quả publish mong đợi:
|
||||
|
||||
- release archive
|
||||
- `SHA256SUMS`
|
||||
- SBOM `CycloneDX` và `SPDX`
|
||||
- chữ ký/chứng chỉ cosign
|
||||
- GitHub Release notes + asset
|
||||
|
||||
### 5) Xác minh sau release
|
||||
|
||||
1. Xác minh GitHub Release asset có thể tải xuống.
|
||||
2. Xác minh GHCR tag cho phiên bản đã release (`vX.Y.Z`) và tag SHA commit release (`sha-<12>`).
|
||||
3. Xác minh các đường dẫn cài đặt phụ thuộc vào release asset (ví dụ tải xuống binary bootstrap).
|
||||
|
||||
### 6) Publish formula Homebrew Core (do bot sở hữu)
|
||||
|
||||
Chạy `Pub Homebrew Core` thủ công:
|
||||
|
||||
- `release_tag`: `vX.Y.Z`
|
||||
- `dry_run`: `true` trước, sau đó `false`
|
||||
|
||||
Cài đặt repository bắt buộc cho non-dry-run:
|
||||
|
||||
- secret: `HOMEBREW_CORE_BOT_TOKEN` (token từ tài khoản bot chuyên dụng, không phải tài khoản maintainer cá nhân)
|
||||
- variable: `HOMEBREW_CORE_BOT_FORK_REPO` (ví dụ `zeroclaw-release-bot/homebrew-core`)
|
||||
- variable tùy chọn: `HOMEBREW_CORE_BOT_EMAIL`
|
||||
|
||||
Các guardrail workflow:
|
||||
|
||||
- release tag phải khớp version `Cargo.toml`
|
||||
- URL nguồn và SHA256 của formula được cập nhật từ tagged tarball
|
||||
- license formula được chuẩn hóa thành `Apache-2.0 OR MIT`
|
||||
- PR được mở từ bot fork vào `Homebrew/homebrew-core:master`
|
||||
|
||||
## Đường dẫn khẩn cấp / khôi phục
|
||||
|
||||
Nếu release push tag thất bại sau khi artifact đã được xác minh:
|
||||
|
||||
1. Sửa vấn đề workflow hoặc packaging trên `master`.
|
||||
2. Chạy lại `Pub Release` thủ công ở chế độ publish với:
|
||||
- `publish_release=true`
|
||||
- `release_tag=<existing tag>`
|
||||
- `release_ref` tự động được pin vào `release_tag` ở chế độ publish
|
||||
3. Xác minh lại asset đã release.
|
||||
|
||||
## Ghi chú vận hành
|
||||
|
||||
- Giữ các thay đổi release nhỏ và có thể đảo ngược.
|
||||
- Dùng một issue/checklist release cho mỗi phiên bản để bàn giao rõ ràng.
|
||||
- Tránh publish từ các feature branch ad-hoc.
|
||||
@@ -1,109 +0,0 @@
|
||||
# Giới hạn tài nguyên
|
||||
|
||||
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
|
||||
>
|
||||
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
|
||||
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Vấn đề
|
||||
|
||||
ZeroClaw có rate limiting (20 actions/hour) nhưng chưa có giới hạn tài nguyên. Một agent bị lỗi lặp vòng có thể:
|
||||
- Làm cạn kiệt bộ nhớ khả dụng
|
||||
- Quay CPU liên tục ở 100%
|
||||
- Lấp đầy ổ đĩa bằng log/output
|
||||
|
||||
---
|
||||
|
||||
## Các giải pháp đề xuất
|
||||
|
||||
### Tùy chọn 1: cgroups v2 (Linux, khuyến nghị)
|
||||
|
||||
Tự động tạo cgroup cho zeroclaw với các giới hạn.
|
||||
|
||||
```bash
|
||||
# Tạo systemd service với giới hạn
|
||||
[Service]
|
||||
MemoryMax=512M
|
||||
CPUQuota=100%
|
||||
IOReadBandwidthMax=/dev/sda 10M
|
||||
IOWriteBandwidthMax=/dev/sda 10M
|
||||
TasksMax=100
|
||||
```
|
||||
|
||||
### Tùy chọn 2: phát hiện deadlock với tokio::task
|
||||
|
||||
Ngăn task starvation.
|
||||
|
||||
```rust
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
pub async fn execute_with_timeout<F, T>(
|
||||
fut: F,
|
||||
cpu_time_limit: Duration,
|
||||
memory_limit: usize,
|
||||
) -> Result<T>
|
||||
where
|
||||
F: Future<Output = Result<T>>,
|
||||
{
|
||||
// CPU timeout
|
||||
timeout(cpu_time_limit, fut).await?
|
||||
}
|
||||
```
|
||||
|
||||
### Tùy chọn 3: memory monitoring
|
||||
|
||||
Theo dõi sử dụng heap và kill nếu vượt giới hạn.
|
||||
|
||||
```rust
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
|
||||
struct LimitedAllocator<A> {
|
||||
inner: A,
|
||||
max_bytes: usize,
|
||||
used: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
unsafe impl<A: GlobalAlloc> GlobalAlloc for LimitedAllocator<A> {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let current = self.used.fetch_add(layout.size(), std::sync::atomic::Ordering::Relaxed);
|
||||
if current + layout.size() > self.max_bytes {
|
||||
std::process::abort();
|
||||
}
|
||||
self.inner.alloc(layout)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config schema
|
||||
|
||||
```toml
|
||||
[resources]
|
||||
# Giới hạn bộ nhớ (tính bằng MB)
|
||||
max_memory_mb = 512
|
||||
max_memory_per_command_mb = 128
|
||||
|
||||
# Giới hạn CPU
|
||||
max_cpu_percent = 50
|
||||
max_cpu_time_seconds = 60
|
||||
|
||||
# Giới hạn Disk I/O
|
||||
max_log_size_mb = 100
|
||||
max_temp_storage_mb = 500
|
||||
|
||||
# Giới hạn process
|
||||
max_subprocesses = 10
|
||||
max_open_files = 100
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thứ tự triển khai
|
||||
|
||||
| Giai đoạn | Tính năng | Công sức | Tác động |
|
||||
|-------|---------|--------|--------|
|
||||
| **P0** | Memory monitoring + kill | Thấp | Cao |
|
||||
| **P1** | CPU timeout mỗi lệnh | Thấp | Cao |
|
||||
| **P2** | Tích hợp cgroups (Linux) | Trung bình | Rất cao |
|
||||
| **P3** | Giới hạn Disk I/O | Trung bình | Trung bình |
|
||||
@@ -1,191 +0,0 @@
|
||||
# Sổ tay Reviewer
|
||||
|
||||
Tài liệu này là người bạn đồng hành vận hành của [`docs/pr-workflow.md`](pr-workflow.md).
|
||||
Để điều hướng tài liệu rộng hơn, xem [`docs/README.md`](README.md).
|
||||
|
||||
## 0. Tóm tắt
|
||||
|
||||
- **Mục đích:** định nghĩa mô hình vận hành reviewer mang tính quyết định, duy trì chất lượng review cao khi khối lượng PR lớn.
|
||||
- **Đối tượng:** maintainer, reviewer và reviewer có hỗ trợ agent.
|
||||
- **Phạm vi:** triage intake, phân tuyến rủi ro-sang-độ-sâu, kiểm tra review sâu, ghi đè tự động hóa và giao thức bàn giao.
|
||||
- **Ngoài phạm vi:** thay thế thẩm quyền chính sách PR trong `CONTRIBUTING.md` hoặc thẩm quyền workflow trong các file CI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Lối tắt theo tình huống review
|
||||
|
||||
Dùng phần này để phân tuyến nhanh trước khi đọc chi tiết đầy đủ.
|
||||
|
||||
### 1.1 Intake thất bại trong 5 phút đầu
|
||||
|
||||
1. Để lại một comment dạng checklist hành động được.
|
||||
2. Dừng review sâu cho đến khi các vấn đề intake được sửa.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 3.1](#31-triage-intake-năm-phút)
|
||||
|
||||
### 1.2 Rủi ro cao hoặc không rõ ràng
|
||||
|
||||
1. Mặc định coi là `risk: high`.
|
||||
2. Yêu cầu review sâu và bằng chứng rollback rõ ràng.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 2](#2-ma-trận-quyết-định-độ-sâu-review)
|
||||
- [Mục 3.3](#33-checklist-review-sâu-rủi-ro-cao)
|
||||
|
||||
### 1.3 Kết quả tự động hóa sai/ồn ào
|
||||
|
||||
1. Áp dụng giao thức ghi đè (`risk: manual`, loại bỏ trùng lặp comment/nhãn).
|
||||
2. Tiếp tục review với lý do rõ ràng.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 5](#5-giao-thức-ghi-đè-tự-động-hóa)
|
||||
|
||||
### 1.4 Cần bàn giao review
|
||||
|
||||
1. Bàn giao với phạm vi/rủi ro/validation/vấn đề chặn.
|
||||
2. Giao hành động tiếp theo cụ thể.
|
||||
|
||||
Xem tiếp:
|
||||
|
||||
- [Mục 6](#6-giao-thức-bàn-giao)
|
||||
|
||||
---
|
||||
|
||||
## 2. Ma trận quyết định độ sâu review
|
||||
|
||||
| Nhãn rủi ro | Đường dẫn thường gặp | Độ sâu review tối thiểu | Bằng chứng bắt buộc |
|
||||
|---|---|---|---|
|
||||
| `risk: low` | docs/tests/chore, thay đổi không ảnh hưởng runtime | 1 reviewer + CI gate | validation cục bộ nhất quán + không mơ hồ hành vi |
|
||||
| `risk: medium` | `src/providers/**`, `src/channels/**`, `src/memory/**`, `src/config/**` | 1 reviewer có hiểu biết về hệ thống con + xác minh hành vi | bằng chứng kịch bản tập trung + tác dụng phụ rõ ràng |
|
||||
| `risk: high` | `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**` | triage nhanh + review sâu + sẵn sàng rollback | kiểm tra bảo mật/failure mode + rõ ràng về rollback |
|
||||
|
||||
Khi không chắc chắn, coi là `risk: high`.
|
||||
|
||||
Nếu việc gán nhãn rủi ro tự động không đúng ngữ cảnh, maintainer có thể áp dụng `risk: manual` và đặt nhãn `risk:*` cuối cùng một cách tường minh.
|
||||
|
||||
---
|
||||
|
||||
## 3. Quy trình review tiêu chuẩn
|
||||
|
||||
### 3.1 Triage intake năm phút
|
||||
|
||||
Cho mỗi PR mới:
|
||||
|
||||
1. Xác nhận độ đầy đủ template (`summary`, `validation`, `security`, `rollback`).
|
||||
2. Xác nhận nhãn hiện diện và hợp lý:
|
||||
- `size:*`, `risk:*`
|
||||
- nhãn phạm vi (ví dụ `provider`, `channel`, `security`)
|
||||
- nhãn có phạm vi module (`channel:*`, `provider:*`, `tool:*`)
|
||||
- nhãn bậc contributor khi áp dụng được
|
||||
3. Xác nhận trạng thái tín hiệu CI (`CI Required Gate`).
|
||||
4. Xác nhận phạm vi là một mối quan tâm (từ chối mega-PR hỗn hợp trừ khi có lý do).
|
||||
5. Xác nhận các yêu cầu tính riêng tư/vệ sinh dữ liệu và diễn đạt test trung lập đã được thỏa mãn.
|
||||
|
||||
Nếu bất kỳ yêu cầu intake nào thất bại, để lại một comment dạng checklist hành động được thay vì review sâu.
|
||||
|
||||
### 3.2 Checklist fast-lane (tất cả PR)
|
||||
|
||||
- Ranh giới phạm vi rõ ràng và đáng tin cậy.
|
||||
- Các lệnh validation hiện diện và kết quả nhất quán.
|
||||
- Các thay đổi hành vi hướng người dùng đã được ghi lại.
|
||||
- Tác giả thể hiện hiểu biết về hành vi và blast radius (đặc biệt với PR có hỗ trợ agent).
|
||||
- Đường dẫn rollback cụ thể (không chỉ là "revert").
|
||||
- Tác động tương thích/migration rõ ràng.
|
||||
- Không có rò rỉ dữ liệu cá nhân/nhạy cảm trong diff artifact; ví dụ/test giữ trung lập và theo phạm vi dự án.
|
||||
- Nếu có ngôn ngữ giống danh tính, nó sử dụng vai trò gốc ZeroClaw/dự án (không phải danh tính cá nhân hay thực tế).
|
||||
- Quy ước đặt tên và ranh giới kiến trúc tuân theo hợp đồng dự án (`AGENTS.md`, `CONTRIBUTING.md`).
|
||||
|
||||
### 3.3 Checklist review sâu (rủi ro cao)
|
||||
|
||||
Với PR rủi ro cao, xác minh ít nhất một ví dụ cụ thể trong mỗi hạng mục:
|
||||
|
||||
- **Ranh giới bảo mật:** hành vi deny-by-default được bảo tồn, không mở rộng phạm vi ngẫu nhiên.
|
||||
- **Failure mode:** xử lý lỗi rõ ràng và suy giảm an toàn.
|
||||
- **Ổn định hợp đồng:** tương thích CLI/config/API được bảo tồn hoặc migration được ghi lại.
|
||||
- **Observability:** lỗi có thể chẩn đoán mà không rò rỉ secret.
|
||||
- **An toàn rollback:** đường dẫn revert và blast radius rõ ràng.
|
||||
|
||||
### 3.4 Phong cách kết quả comment review
|
||||
|
||||
Ưu tiên comment dạng checklist với một kết quả rõ ràng:
|
||||
|
||||
- **Sẵn sàng merge** (giải thích lý do).
|
||||
- **Cần tác giả hành động** (danh sách vấn đề chặn có thứ tự).
|
||||
- **Cần review bảo mật/runtime sâu hơn** (nêu rõ rủi ro và bằng chứng yêu cầu).
|
||||
|
||||
Tránh comment mơ hồ tạo ra độ trễ qua lại không cần thiết.
|
||||
|
||||
---
|
||||
|
||||
## 4. Triage issue và quản trị backlog
|
||||
|
||||
### 4.1 Sổ tay nhãn triage issue
|
||||
|
||||
Dùng nhãn để giữ backlog có thể hành động:
|
||||
|
||||
- `r:needs-repro` cho báo cáo lỗi chưa đầy đủ.
|
||||
- `r:support` cho câu hỏi sử dụng/hỗ trợ nên chuyển hướng ngoài bug backlog.
|
||||
- `duplicate` / `invalid` cho trùng lặp/nhiễu không thể hành động.
|
||||
- `no-stale` cho công việc đã được chấp nhận đang chờ vấn đề chặn bên ngoài.
|
||||
- Yêu cầu biên tập khi log/payload chứa định danh cá nhân hoặc dữ liệu nhạy cảm.
|
||||
|
||||
### 4.2 Giao thức cắt tỉa backlog PR
|
||||
|
||||
Khi nhu cầu review vượt quá năng lực, áp dụng thứ tự này:
|
||||
|
||||
1. Giữ PR bug/security đang hoạt động (`size: XS/S`) ở đầu hàng đợi.
|
||||
2. Yêu cầu các PR chồng chéo hợp nhất; đóng các PR cũ hơn là `superseded` sau khi xác nhận.
|
||||
3. Đánh dấu PR ngủ đông là `stale-candidate` trước khi cửa sổ đóng stale bắt đầu.
|
||||
4. Yêu cầu rebase + validation mới trước khi mở lại công việc kỹ thuật stale/superseded.
|
||||
|
||||
---
|
||||
|
||||
## 5. Giao thức ghi đè tự động hóa
|
||||
|
||||
Dùng khi kết quả tự động hóa tạo ra tác dụng phụ cho review:
|
||||
|
||||
1. **Nhãn rủi ro sai:** thêm `risk: manual`, rồi đặt nhãn `risk:*` mong muốn.
|
||||
2. **Tự đóng sai trên triage issue:** mở lại issue, xóa nhãn route, để lại một comment làm rõ.
|
||||
3. **Spam/nhiễu nhãn:** giữ một comment maintainer chuẩn tắc và xóa nhãn route dư thừa.
|
||||
4. **Phạm vi PR mơ hồ:** yêu cầu chia nhỏ trước khi review sâu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Giao thức bàn giao
|
||||
|
||||
Nếu bàn giao review cho maintainer/agent khác, bao gồm:
|
||||
|
||||
1. Tóm tắt phạm vi.
|
||||
2. Phân loại rủi ro hiện tại và lý do.
|
||||
3. Những gì đã được validate.
|
||||
4. Các vấn đề chặn mở.
|
||||
5. Hành động tiếp theo được đề xuất.
|
||||
|
||||
---
|
||||
|
||||
## 7. Vệ sinh hàng đợi hàng tuần
|
||||
|
||||
- Review hàng đợi stale và chỉ áp dụng `no-stale` cho công việc đã được chấp nhận nhưng bị chặn.
|
||||
- Ưu tiên PR bug/security `size: XS/S` trước.
|
||||
- Chuyển đổi các issue hỗ trợ tái diễn thành cập nhật tài liệu và hướng dẫn auto-response.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tài liệu liên quan
|
||||
|
||||
- [README.md](README.md) — phân loại và điều hướng tài liệu.
|
||||
- [pr-workflow.md](pr-workflow.md) — workflow quản trị và hợp đồng merge.
|
||||
- [ci-map.md](ci-map.md) — bản đồ quyền sở hữu và triage CI.
|
||||
- [actions-source-policy.md](actions-source-policy.md) — chính sách allowlist nguồn action.
|
||||
|
||||
---
|
||||
|
||||
## 9. Ghi chú bảo trì
|
||||
|
||||
- **Chủ sở hữu:** các maintainer chịu trách nhiệm về chất lượng review và thông lượng hàng đợi.
|
||||
- **Kích hoạt cập nhật:** thay đổi chính sách PR, thay đổi mô hình phân tuyến rủi ro hoặc thay đổi hành vi ghi đè tự động hóa.
|
||||
- **Lần review cuối:** 2026-02-18.
|
||||
@@ -1,200 +0,0 @@
|
||||
# Chiến lược sandboxing
|
||||
|
||||
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
|
||||
>
|
||||
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
|
||||
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Vấn đề
|
||||
|
||||
ZeroClaw hiện có application-layer security (allowlists, path blocking, command injection protection) nhưng thiếu cơ chế cách ly cấp hệ điều hành. Nếu kẻ tấn công nằm trong allowlist, họ có thể chạy bất kỳ lệnh nào được cho phép với quyền của user zeroclaw.
|
||||
|
||||
## Các giải pháp đề xuất
|
||||
|
||||
### Tùy chọn 1: tích hợp Firejail (khuyến nghị cho Linux)
|
||||
|
||||
Firejail cung cấp sandboxing ở user-space với overhead tối thiểu.
|
||||
|
||||
```rust
|
||||
// src/security/firejail.rs
|
||||
use std::process::Command;
|
||||
|
||||
pub struct FirejailSandbox {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl FirejailSandbox {
|
||||
pub fn new() -> Self {
|
||||
let enabled = which::which("firejail").is_ok();
|
||||
Self { enabled }
|
||||
}
|
||||
|
||||
pub fn wrap_command(&self, cmd: &mut Command) -> &mut Command {
|
||||
if !self.enabled {
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Firejail bọc bất kỳ lệnh nào với sandboxing
|
||||
let mut jail = Command::new("firejail");
|
||||
jail.args([
|
||||
"--private=home", // Thư mục home mới
|
||||
"--private-dev", // /dev tối giản
|
||||
"--nosound", // Không âm thanh
|
||||
"--no3d", // Không tăng tốc 3D
|
||||
"--novideo", // Không thiết bị video
|
||||
"--nowheel", // Không thiết bị nhập liệu
|
||||
"--notv", // Không thiết bị TV
|
||||
"--noprofile", // Bỏ qua tải profile
|
||||
"--quiet", // Tắt cảnh báo
|
||||
]);
|
||||
|
||||
// Gắn thêm lệnh gốc
|
||||
if let Some(program) = cmd.get_program().to_str() {
|
||||
jail.arg(program);
|
||||
}
|
||||
for arg in cmd.get_args() {
|
||||
if let Some(s) = arg.to_str() {
|
||||
jail.arg(s);
|
||||
}
|
||||
}
|
||||
|
||||
// Thay thế lệnh gốc bằng firejail wrapper
|
||||
*cmd = jail;
|
||||
cmd
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Tùy chọn config:**
|
||||
```toml
|
||||
[security]
|
||||
enable_sandbox = true
|
||||
sandbox_backend = "firejail" # hoặc "none", "bubblewrap", "docker"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tùy chọn 2: Bubblewrap (di động, không cần root)
|
||||
|
||||
Bubblewrap dùng user namespaces để tạo container.
|
||||
|
||||
```bash
|
||||
# Cài bubblewrap
|
||||
sudo apt install bubblewrap
|
||||
|
||||
# Bọc lệnh:
|
||||
bwrap --ro-bind /usr /usr \
|
||||
--dev /dev \
|
||||
--proc /proc \
|
||||
--bind /workspace /workspace \
|
||||
--unshare-all \
|
||||
--share-net \
|
||||
--die-with-parent \
|
||||
-- /bin/sh -c "command"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tùy chọn 3: Docker-in-Docker (nặng nhưng cách ly hoàn toàn)
|
||||
|
||||
Chạy các công cụ agent trong container tạm thời.
|
||||
|
||||
```rust
|
||||
pub struct DockerSandbox {
|
||||
image: String,
|
||||
}
|
||||
|
||||
impl DockerSandbox {
|
||||
pub async fn execute(&self, command: &str, workspace: &Path) -> Result<String> {
|
||||
let output = Command::new("docker")
|
||||
.args([
|
||||
"run", "--rm",
|
||||
"--memory", "512m",
|
||||
"--cpus", "1.0",
|
||||
"--network", "none",
|
||||
"--volume", &format!("{}:/workspace", workspace.display()),
|
||||
&self.image,
|
||||
"sh", "-c", command
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tùy chọn 4: Landlock (Linux kernel LSM, Rust native)
|
||||
|
||||
Landlock cung cấp kiểm soát truy cập hệ thống file mà không cần container.
|
||||
|
||||
```rust
|
||||
use landlock::{Ruleset, AccessFS};
|
||||
|
||||
pub fn apply_landlock() -> Result<()> {
|
||||
let ruleset = Ruleset::new()
|
||||
.set_access_fs(AccessFS::read_file | AccessFS::write_file)
|
||||
.add_path(Path::new("/workspace"), AccessFS::read_file | AccessFS::write_file)?
|
||||
.add_path(Path::new("/tmp"), AccessFS::read_file | AccessFS::write_file)?
|
||||
.restrict_self()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Thứ tự triển khai ưu tiên
|
||||
|
||||
| Giai đoạn | Giải pháp | Công sức | Tăng cường bảo mật |
|
||||
|-------|----------|--------|---------------|
|
||||
| **P0** | Landlock (chỉ Linux, native) | Thấp | Cao (filesystem) |
|
||||
| **P1** | Tích hợp Firejail | Thấp | Rất cao |
|
||||
| **P2** | Bubblewrap wrapper | Trung bình | Rất cao |
|
||||
| **P3** | Docker sandbox mode | Cao | Hoàn toàn |
|
||||
|
||||
## Mở rộng config schema
|
||||
|
||||
```toml
|
||||
[security.sandbox]
|
||||
enabled = true
|
||||
backend = "auto" # auto | firejail | bubblewrap | landlock | docker | none
|
||||
|
||||
# Dành riêng cho Firejail
|
||||
[security.sandbox.firejail]
|
||||
extra_args = ["--seccomp", "--caps.drop=all"]
|
||||
|
||||
# Dành riêng cho Landlock
|
||||
[security.sandbox.landlock]
|
||||
readonly_paths = ["/usr", "/bin", "/lib"]
|
||||
readwrite_paths = ["$HOME/workspace", "/tmp/zeroclaw"]
|
||||
```
|
||||
|
||||
## Chiến lược kiểm thử
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sandbox_blocks_path_traversal() {
|
||||
// Thử đọc /etc/passwd qua sandbox
|
||||
let result = sandboxed_execute("cat /etc/passwd");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_allows_workspace_access() {
|
||||
let result = sandboxed_execute("ls /workspace");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_no_network_isolation() {
|
||||
// Đảm bảo mạng bị chặn khi được cấu hình
|
||||
let result = sandboxed_execute("curl http://example.com");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,188 +0,0 @@
|
||||
# Lộ trình cải tiến bảo mật
|
||||
|
||||
> ⚠️ **Trạng thái: Đề xuất / Lộ trình**
|
||||
>
|
||||
> Tài liệu này mô tả các hướng tiếp cận đề xuất và có thể bao gồm các lệnh hoặc cấu hình giả định.
|
||||
> Để biết hành vi runtime hiện tại, xem [config-reference.md](config-reference.md), [operations-runbook.md](operations-runbook.md), và [troubleshooting.md](troubleshooting.md).
|
||||
|
||||
## Tình trạng bảo mật hiện tại: nền tảng vững chắc
|
||||
|
||||
ZeroClaw đã có **application-layer security xuất sắc**:
|
||||
|
||||
✅ Command allowlist (không phải blocklist)
|
||||
✅ Bảo vệ path traversal
|
||||
✅ Chặn command injection (`$(...)`, backticks, `&&`, `>`)
|
||||
✅ Cách ly secret (API key không bị rò rỉ ra shell)
|
||||
✅ Rate limiting (20 actions/hour)
|
||||
✅ Channel authorization (rỗng = từ chối tất cả, `*` = cho phép tất cả)
|
||||
✅ Phân loại rủi ro (Low/Medium/High)
|
||||
✅ Làm sạch biến môi trường
|
||||
✅ Chặn forbidden paths
|
||||
✅ Độ phủ kiểm thử toàn diện (1.017 test)
|
||||
|
||||
## Những gì còn thiếu: cách ly cấp hệ điều hành
|
||||
|
||||
🔴 Chưa có sandboxing cấp OS (chroot, containers, namespaces)
|
||||
🔴 Chưa có giới hạn tài nguyên (giới hạn CPU, memory, disk I/O)
|
||||
🔴 Chưa có audit logging chống giả mạo
|
||||
🔴 Chưa có syscall filtering (seccomp)
|
||||
|
||||
---
|
||||
|
||||
## So sánh: ZeroClaw vs PicoClaw vs production grade
|
||||
|
||||
| Tính năng | PicoClaw | ZeroClaw hiện tại | ZeroClaw + lộ trình | Mục tiêu production |
|
||||
|---------|----------|--------------|-------------------|-------------------|
|
||||
| **Kích thước binary** | ~8MB | **3.4MB** ✅ | 3.5-4MB | < 5MB |
|
||||
| **RAM** | < 10MB | **< 5MB** ✅ | < 10MB | < 20MB |
|
||||
| **Thời gian startup** | < 1s | **< 10ms** ✅ | < 50ms | < 100ms |
|
||||
| **Command allowlist** | Không rõ | ✅ Có | ✅ Có | ✅ Có |
|
||||
| **Path blocking** | Không rõ | ✅ Có | ✅ Có | ✅ Có |
|
||||
| **Injection protection** | Không rõ | ✅ Có | ✅ Có | ✅ Có |
|
||||
| **OS sandbox** | Không | ❌ Không | ✅ Firejail/Landlock | ✅ Container/namespaces |
|
||||
| **Resource limits** | Không | ❌ Không | ✅ cgroups/Monitor | ✅ Full cgroups |
|
||||
| **Audit logging** | Không | ❌ Không | ✅ Ký HMAC | ✅ Tích hợp SIEM |
|
||||
| **Điểm bảo mật** | C | **B+** | **A-** | **A+** |
|
||||
|
||||
---
|
||||
|
||||
## Lộ trình triển khai
|
||||
|
||||
### Giai đoạn 1: kết quả nhanh (1-2 tuần)
|
||||
|
||||
**Mục tiêu**: giải quyết các thiếu sót nghiêm trọng với độ phức tạp tối thiểu
|
||||
|
||||
| Nhiệm vụ | File | Công sức | Tác động |
|
||||
|------|------|--------|-------|
|
||||
| Landlock filesystem sandbox | `src/security/landlock.rs` | 2 ngày | Cao |
|
||||
| Memory monitoring + OOM kill | `src/resources/memory.rs` | 1 ngày | Cao |
|
||||
| CPU timeout mỗi lệnh | `src/tools/shell.rs` | 1 ngày | Cao |
|
||||
| Audit logging cơ bản | `src/security/audit.rs` | 2 ngày | Trung bình |
|
||||
| Cập nhật config schema | `src/config/schema.rs` | 1 ngày | - |
|
||||
|
||||
**Kết quả bàn giao**:
|
||||
- Linux: truy cập filesystem bị giới hạn trong workspace
|
||||
- Tất cả nền tảng: bảo vệ memory/CPU chống lệnh chạy vô hạn
|
||||
- Tất cả nền tảng: audit trail chống giả mạo
|
||||
|
||||
---
|
||||
|
||||
### Giai đoạn 2: tích hợp nền tảng (2-3 tuần)
|
||||
|
||||
**Mục tiêu**: tích hợp sâu với OS để cách ly cấp production
|
||||
|
||||
| Nhiệm vụ | Công sức | Tác động |
|
||||
|------|--------|-------|
|
||||
| Tự phát hiện Firejail + wrapping | 3 ngày | Rất cao |
|
||||
| Bubblewrap wrapper cho macOS/*nix | 4 ngày | Rất cao |
|
||||
| Tích hợp cgroups v2 systemd | 3 ngày | Cao |
|
||||
| Syscall filtering với seccomp | 5 ngày | Cao |
|
||||
| Audit log query CLI | 2 ngày | Trung bình |
|
||||
|
||||
**Kết quả bàn giao**:
|
||||
- Linux: cách ly hoàn toàn như container qua Firejail
|
||||
- macOS: cách ly filesystem với Bubblewrap
|
||||
- Linux: thực thi giới hạn tài nguyên qua cgroups
|
||||
- Linux: allowlist syscall
|
||||
|
||||
---
|
||||
|
||||
### Giai đoạn 3: hardening production (1-2 tuần)
|
||||
|
||||
**Mục tiêu**: các tính năng bảo mật doanh nghiệp
|
||||
|
||||
| Nhiệm vụ | Công sức | Tác động |
|
||||
|------|--------|-------|
|
||||
| Docker sandbox mode | 3 ngày | Cao |
|
||||
| Certificate pinning cho channels | 2 ngày | Trung bình |
|
||||
| Xác minh config đã ký | 2 ngày | Trung bình |
|
||||
| Xuất audit tương thích SIEM | 2 ngày | Trung bình |
|
||||
| Tự kiểm tra bảo mật (`zeroclaw audit --check`) | 1 ngày | Thấp |
|
||||
|
||||
**Kết quả bàn giao**:
|
||||
- Tùy chọn cách ly thực thi dựa trên Docker
|
||||
- HTTPS certificate pinning cho channel webhooks
|
||||
- Xác minh chữ ký file config
|
||||
- Xuất audit JSON/CSV cho phân tích ngoài
|
||||
|
||||
---
|
||||
|
||||
## Xem trước config schema mới
|
||||
|
||||
```toml
|
||||
[security]
|
||||
level = "strict" # relaxed | default | strict | paranoid
|
||||
|
||||
# Cấu hình sandbox
|
||||
[security.sandbox]
|
||||
enabled = true
|
||||
backend = "auto" # auto | firejail | bubblewrap | landlock | docker | none
|
||||
|
||||
# Giới hạn tài nguyên
|
||||
[resources]
|
||||
max_memory_mb = 512
|
||||
max_memory_per_command_mb = 128
|
||||
max_cpu_percent = 50
|
||||
max_cpu_time_seconds = 60
|
||||
max_subprocesses = 10
|
||||
|
||||
# Audit logging
|
||||
[security.audit]
|
||||
enabled = true
|
||||
log_path = "~/.config/zeroclaw/audit.log"
|
||||
sign_events = true
|
||||
max_size_mb = 100
|
||||
|
||||
# Autonomy (hiện có, được cải thiện)
|
||||
[autonomy]
|
||||
level = "supervised" # readonly | supervised | full
|
||||
allowed_commands = ["git", "ls", "cat", "grep", "find"]
|
||||
forbidden_paths = ["/etc", "/root", "~/.ssh"]
|
||||
require_approval_for_medium_risk = true
|
||||
block_high_risk_commands = true
|
||||
max_actions_per_hour = 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Xem trước lệnh CLI
|
||||
|
||||
```bash
|
||||
# Kiểm tra trạng thái bảo mật
|
||||
zeroclaw security --check
|
||||
# → ✓ Sandbox: Firejail active
|
||||
# → ✓ Audit logging enabled (42 events today)
|
||||
# → → Resource limits: 512MB mem, 50% CPU
|
||||
|
||||
# Truy vấn audit log
|
||||
zeroclaw audit --user @alice --since 24h
|
||||
zeroclaw audit --risk high --violations-only
|
||||
zeroclaw audit --verify-signatures
|
||||
|
||||
# Kiểm tra sandbox
|
||||
zeroclaw sandbox --test
|
||||
# → Testing isolation...
|
||||
# ✓ Cannot read /etc/passwd
|
||||
# ✓ Cannot access ~/.ssh
|
||||
# ✓ Can read /workspace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tóm tắt
|
||||
|
||||
**ZeroClaw đã an toàn hơn PicoClaw** với:
|
||||
- Binary nhỏ hơn 50% (3.4MB so với 8MB)
|
||||
- RAM ít hơn 50% (< 5MB so với < 10MB)
|
||||
- Startup nhanh hơn 100 lần (< 10ms so với < 1s)
|
||||
- Policy engine bảo mật toàn diện
|
||||
- Độ phủ kiểm thử rộng
|
||||
|
||||
**Khi triển khai lộ trình này**, ZeroClaw sẽ trở thành:
|
||||
- Cấp production với OS-level sandboxing
|
||||
- Nhận biết tài nguyên với bảo vệ memory/CPU
|
||||
- Sẵn sàng audit với logging chống giả mạo
|
||||
- Sẵn sàng doanh nghiệp với các cấp độ bảo mật có thể cấu hình
|
||||
|
||||
**Công sức ước tính**: 4-7 tuần để triển khai đầy đủ
|
||||
**Giá trị**: biến ZeroClaw từ "an toàn để kiểm thử" thành "an toàn cho production"
|
||||
@@ -1,22 +0,0 @@
|
||||
# Tài liệu bảo mật
|
||||
|
||||
Hướng dẫn bảo mật hiện tại và đề xuất cải tiến.
|
||||
|
||||
## Hành vi hiện tại trước tiên
|
||||
|
||||
Để biết hành vi runtime hiện tại, bắt đầu tại đây:
|
||||
|
||||
- Tham chiếu config: [../config-reference.md](../config-reference.md)
|
||||
- Sổ tay vận hành: [../operations-runbook.md](../operations-runbook.md)
|
||||
- Xử lý sự cố: [../troubleshooting.md](../troubleshooting.md)
|
||||
|
||||
## Tài liệu đề xuất / Lộ trình
|
||||
|
||||
Các tài liệu sau theo định hướng đề xuất rõ ràng và có thể bao gồm các ví dụ CLI/config chưa triển khai:
|
||||
|
||||
- [../agnostic-security.md](../agnostic-security.md)
|
||||
- [../frictionless-security.md](../frictionless-security.md)
|
||||
- [../sandboxing.md](../sandboxing.md)
|
||||
- [../resource-limits.md](../resource-limits.md)
|
||||
- [../audit-logging.md](../audit-logging.md)
|
||||
- [../security-roadmap.md](../security-roadmap.md)
|
||||
@@ -1,236 +0,0 @@
|
||||
# Khắc phục sự cố ZeroClaw
|
||||
|
||||
Các lỗi thường gặp khi cài đặt và chạy, kèm cách khắc phục.
|
||||
|
||||
Xác minh lần cuối: **2026-02-20**.
|
||||
|
||||
## Cài đặt / Bootstrap
|
||||
|
||||
### Không tìm thấy `cargo`
|
||||
|
||||
Triệu chứng:
|
||||
|
||||
- bootstrap thoát với lỗi `cargo is not installed`
|
||||
|
||||
Khắc phục:
|
||||
|
||||
```bash
|
||||
./install.sh --install-rust
|
||||
```
|
||||
|
||||
Hoặc cài từ <https://rustup.rs/>.
|
||||
|
||||
### Thiếu thư viện hệ thống để build
|
||||
|
||||
Triệu chứng:
|
||||
|
||||
- build thất bại do lỗi trình biên dịch hoặc `pkg-config`
|
||||
|
||||
Khắc phục:
|
||||
|
||||
```bash
|
||||
./install.sh --install-system-deps
|
||||
```
|
||||
|
||||
### Build thất bại trên máy ít RAM / ít dung lượng
|
||||
|
||||
Triệu chứng:
|
||||
|
||||
- `cargo build --release` bị kill (`signal: 9`, OOM killer, hoặc `cannot allocate memory`)
|
||||
- Build vẫn lỗi sau khi thêm swap vì hết dung lượng ổ đĩa
|
||||
|
||||
Nguyên nhân:
|
||||
|
||||
- RAM lúc chạy (<5MB) khác xa RAM lúc biên dịch.
|
||||
- Build đầy đủ từ mã nguồn có thể cần **2 GB RAM + swap** và **6+ GB dung lượng trống**.
|
||||
- Bật swap trên ổ nhỏ có thể tránh OOM RAM nhưng vẫn lỗi vì hết dung lượng.
|
||||
|
||||
Cách tốt nhất cho máy hạn chế tài nguyên:
|
||||
|
||||
```bash
|
||||
./install.sh --prefer-prebuilt
|
||||
```
|
||||
|
||||
Chế độ chỉ dùng binary (không build từ nguồn):
|
||||
|
||||
```bash
|
||||
./install.sh --prebuilt-only
|
||||
```
|
||||
|
||||
Nếu bắt buộc phải build từ nguồn trên máy yếu:
|
||||
|
||||
1. Chỉ thêm swap nếu còn đủ dung lượng cho cả swap lẫn kết quả build.
|
||||
1. Giới hạn số luồng build:
|
||||
|
||||
```bash
|
||||
CARGO_BUILD_JOBS=1 cargo build --release --locked
|
||||
```
|
||||
|
||||
1. Bỏ bớt feature nặng khi không cần Matrix:
|
||||
|
||||
```bash
|
||||
cargo build --release --locked --no-default-features --features hardware
|
||||
```
|
||||
|
||||
1. Cross-compile trên máy mạnh hơn rồi copy binary sang máy đích.
|
||||
|
||||
### Build rất chậm hoặc có vẻ bị treo
|
||||
|
||||
Triệu chứng:
|
||||
|
||||
- `cargo check` / `cargo build` dừng lâu ở `Checking zeroclaw`
|
||||
- Lặp lại thông báo `Blocking waiting for file lock on package cache` hoặc `build directory`
|
||||
|
||||
Nguyên nhân:
|
||||
|
||||
- Thư viện Matrix E2EE (`matrix-sdk`, `ruma`, `vodozemac`) lớn và tốn thời gian kiểm tra kiểu.
|
||||
- TLS + crypto native build script (`aws-lc-sys`, `ring`) tăng thời gian biên dịch đáng kể.
|
||||
- `rusqlite` với SQLite tích hợp biên dịch mã C cục bộ.
|
||||
- Chạy nhiều cargo job/worktree song song gây tranh chấp file lock.
|
||||
|
||||
Kiểm tra nhanh:
|
||||
|
||||
```bash
|
||||
cargo check --timings
|
||||
cargo tree -d
|
||||
```
|
||||
|
||||
Báo cáo thời gian được ghi tại `target/cargo-timings/cargo-timing.html`.
|
||||
|
||||
Lặp nhanh hơn khi không cần kênh Matrix:
|
||||
|
||||
```bash
|
||||
cargo check --no-default-features --features hardware
|
||||
```
|
||||
|
||||
Lệnh này bỏ qua `channel-matrix` và giảm đáng kể thời gian biên dịch.
|
||||
|
||||
Build với Matrix:
|
||||
|
||||
```bash
|
||||
cargo check --no-default-features --features hardware,channel-matrix
|
||||
```
|
||||
|
||||
Giảm tranh chấp lock:
|
||||
|
||||
```bash
|
||||
pgrep -af "cargo (check|build|test)|cargo check|cargo build|cargo test"
|
||||
```
|
||||
|
||||
Dừng các cargo job không liên quan trước khi build.
|
||||
|
||||
### Không tìm thấy lệnh `zeroclaw` sau cài đặt
|
||||
|
||||
Triệu chứng:
|
||||
|
||||
- Cài đặt thành công nhưng shell không tìm thấy `zeroclaw`
|
||||
|
||||
Khắc phục:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
which zeroclaw
|
||||
```
|
||||
|
||||
Thêm vào shell profile nếu cần giữ lâu dài.
|
||||
|
||||
## Runtime / Gateway
|
||||
|
||||
### Không kết nối được gateway
|
||||
|
||||
Kiểm tra:
|
||||
|
||||
```bash
|
||||
zeroclaw status
|
||||
zeroclaw doctor
|
||||
```
|
||||
|
||||
Xác minh `~/.zeroclaw/config.toml`:
|
||||
|
||||
- `[gateway].host` (mặc định `127.0.0.1`)
|
||||
- `[gateway].port` (mặc định `3000`)
|
||||
- `allow_public_bind` chỉ bật khi cố ý mở truy cập LAN/public
|
||||
|
||||
### Lỗi ghép nối / xác thực webhook
|
||||
|
||||
Kiểm tra:
|
||||
|
||||
1. Đảm bảo đã hoàn tất ghép nối (luồng `/pair`)
|
||||
2. Đảm bảo bearer token còn hiệu lực
|
||||
3. Chạy lại chẩn đoán:
|
||||
|
||||
```bash
|
||||
zeroclaw doctor
|
||||
```
|
||||
|
||||
## Sự cố kênh
|
||||
|
||||
### Telegram xung đột: `terminated by other getUpdates request`
|
||||
|
||||
Nguyên nhân:
|
||||
|
||||
- Nhiều poller dùng chung bot token
|
||||
|
||||
Khắc phục:
|
||||
|
||||
- Chỉ giữ một runtime đang chạy cho token đó
|
||||
- Dừng các tiến trình `zeroclaw daemon` / `zeroclaw channel start` thừa
|
||||
|
||||
### Kênh không khỏe trong `channel doctor`
|
||||
|
||||
Kiểm tra:
|
||||
|
||||
```bash
|
||||
zeroclaw channel doctor
|
||||
```
|
||||
|
||||
Sau đó xác minh thông tin xác thực và trường allowlist cho từng kênh trong config.
|
||||
|
||||
## Chế độ dịch vụ
|
||||
|
||||
### Dịch vụ đã cài nhưng không chạy
|
||||
|
||||
Kiểm tra:
|
||||
|
||||
```bash
|
||||
zeroclaw service status
|
||||
```
|
||||
|
||||
Khôi phục:
|
||||
|
||||
```bash
|
||||
zeroclaw service stop
|
||||
zeroclaw service start
|
||||
```
|
||||
|
||||
Xem log trên Linux:
|
||||
|
||||
```bash
|
||||
journalctl --user -u zeroclaw.service -f
|
||||
```
|
||||
|
||||
## URL cài đặt
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/master/install.sh | bash
|
||||
```
|
||||
|
||||
## Vẫn chưa giải quyết được?
|
||||
|
||||
Thu thập và đính kèm các thông tin sau khi tạo issue:
|
||||
|
||||
```bash
|
||||
zeroclaw --version
|
||||
zeroclaw status
|
||||
zeroclaw doctor
|
||||
zeroclaw channel doctor
|
||||
```
|
||||
|
||||
Kèm thêm: hệ điều hành, cách cài đặt, và đoạn config đã ẩn bí mật.
|
||||
|
||||
## Tài liệu liên quan
|
||||
|
||||
- [operations-runbook.md](operations-runbook.md)
|
||||
- [one-click-bootstrap.md](one-click-bootstrap.md)
|
||||
- [channels-reference.md](channels-reference.md)
|
||||
- [network-deployment.md](network-deployment.md)
|
||||
@@ -1,142 +0,0 @@
|
||||
# Thiết lập Z.AI GLM
|
||||
|
||||
ZeroClaw hỗ trợ các model GLM của Z.AI thông qua các endpoint tương thích OpenAI.
|
||||
Hướng dẫn cấu hình thực tế theo provider hiện tại của ZeroClaw.
|
||||
|
||||
## Tổng quan
|
||||
|
||||
ZeroClaw hỗ trợ sẵn các alias và endpoint Z.AI sau đây:
|
||||
|
||||
| Alias | Endpoint | Ghi chú |
|
||||
|-------|----------|---------|
|
||||
| `zai` | `https://api.z.ai/api/coding/paas/v4` | Endpoint toàn cầu |
|
||||
| `zai-cn` | `https://open.bigmodel.cn/api/paas/v4` | Endpoint Trung Quốc |
|
||||
|
||||
Nếu bạn cần base URL tùy chỉnh, xem `docs/custom-providers.md`.
|
||||
|
||||
## Thiết lập
|
||||
|
||||
### Bắt đầu nhanh
|
||||
|
||||
```bash
|
||||
zeroclaw onboard \
|
||||
--provider "zai" \
|
||||
--api-key "YOUR_ZAI_API_KEY"
|
||||
```
|
||||
|
||||
### Cấu hình thủ công
|
||||
|
||||
Chỉnh sửa `~/.zeroclaw/config.toml`:
|
||||
|
||||
```toml
|
||||
api_key = "YOUR_ZAI_API_KEY"
|
||||
default_provider = "zai"
|
||||
default_model = "glm-5"
|
||||
default_temperature = 0.7
|
||||
```
|
||||
|
||||
## Các model hiện có
|
||||
|
||||
| Model | Mô tả |
|
||||
|-------|-------|
|
||||
| `glm-5` | Mặc định khi onboarding; khả năng suy luận mạnh nhất |
|
||||
| `glm-4.7` | Chất lượng đa năng cao |
|
||||
| `glm-4.6` | Mức cơ bản cân bằng |
|
||||
| `glm-4.5-air` | Tùy chọn độ trễ thấp hơn |
|
||||
|
||||
Khả năng khả dụng của model có thể thay đổi theo tài khoản/khu vực, hãy dùng API `/models` khi không chắc chắn.
|
||||
|
||||
## Xác minh thiết lập
|
||||
|
||||
### Kiểm tra bằng curl
|
||||
|
||||
```bash
|
||||
# Test OpenAI-compatible endpoint
|
||||
curl -X POST "https://api.z.ai/api/coding/paas/v4/chat/completions" \
|
||||
-H "Authorization: Bearer YOUR_ZAI_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "glm-5",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}'
|
||||
```
|
||||
|
||||
Phản hồi mong đợi:
|
||||
```json
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Hello! How can I help you today?",
|
||||
"role": "assistant"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
### Kiểm tra bằng ZeroClaw CLI
|
||||
|
||||
```bash
|
||||
# Test agent directly
|
||||
echo "Hello" | zeroclaw agent
|
||||
|
||||
# Check status
|
||||
zeroclaw status
|
||||
```
|
||||
|
||||
## Biến môi trường
|
||||
|
||||
Thêm vào file `.env` của bạn:
|
||||
|
||||
```bash
|
||||
# Z.AI API Key
|
||||
ZAI_API_KEY=your-id.secret
|
||||
|
||||
# Optional generic key (used by many providers)
|
||||
# API_KEY=your-id.secret
|
||||
```
|
||||
|
||||
Định dạng key là `id.secret` (ví dụ: `abc123.xyz789`).
|
||||
|
||||
## Xử lý sự cố
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Triệu chứng:** Lỗi `rate_limited`
|
||||
|
||||
**Giải pháp:**
|
||||
- Chờ và thử lại
|
||||
- Kiểm tra giới hạn gói Z.AI của bạn
|
||||
- Thử `glm-4.5-air` để có độ trễ thấp hơn và khả năng chịu đựng quota cao hơn
|
||||
|
||||
### Lỗi xác thực
|
||||
|
||||
**Triệu chứng:** Lỗi 401 hoặc 403
|
||||
|
||||
**Giải pháp:**
|
||||
- Xác minh định dạng API key là `id.secret`
|
||||
- Kiểm tra key chưa hết hạn
|
||||
- Đảm bảo không có khoảng trắng thừa trong key
|
||||
|
||||
### Model không tìm thấy
|
||||
|
||||
**Triệu chứng:** Lỗi model không khả dụng
|
||||
|
||||
**Giải pháp:**
|
||||
- Liệt kê các model có sẵn:
|
||||
```bash
|
||||
curl -s "https://api.z.ai/api/coding/paas/v4/models" \
|
||||
-H "Authorization: Bearer YOUR_ZAI_API_KEY" | jq '.data[].id'
|
||||
```
|
||||
|
||||
## Lấy API Key
|
||||
|
||||
1. Truy cập [Z.AI](https://z.ai)
|
||||
2. Đăng ký Coding Plan
|
||||
3. Tạo API key từ dashboard
|
||||
4. Định dạng key: `id.secret` (ví dụ: `abc123.xyz789`)
|
||||
|
||||
## Tài liệu liên quan
|
||||
|
||||
- [ZeroClaw README](README.md)
|
||||
- [Custom Provider Endpoints](./custom-providers.md)
|
||||
- [Contributing Guide](../../../CONTRIBUTING.md)
|
||||
@@ -0,0 +1,73 @@
|
||||
# OpenAI Temperature Compatibility Reference
|
||||
|
||||
This document provides empirical evidence for temperature parameter compatibility across OpenAI models.
|
||||
|
||||
## Summary
|
||||
|
||||
Different OpenAI model families have different temperature requirements:
|
||||
|
||||
- **Reasoning models** (o-series, gpt-5 base variants): Only accept `temperature=1.0`
|
||||
- **Search models**: Do not accept temperature parameter (must be omitted)
|
||||
- **Standard models** (gpt-3.5, gpt-4, gpt-4o): Accept flexible temperature values (0.0-2.0)
|
||||
|
||||
## Tested Models
|
||||
|
||||
### Models Requiring temperature=1.0
|
||||
|
||||
| Model | Accepts 0.7 | Accepts 1.0 | Recommendation |
|
||||
|-------|-------------|-------------|----------------|
|
||||
| o1 | ❌ | ✅ | USE_1.0 |
|
||||
| o1-2024-12-17 | ❌ | ✅ | USE_1.0 |
|
||||
| o3 | ❌ | ✅ | USE_1.0 |
|
||||
| o3-2025-04-16 | ❌ | ✅ | USE_1.0 |
|
||||
| o3-mini | ❌ | ✅ | USE_1.0 |
|
||||
| o3-mini-2025-01-31 | ❌ | ✅ | USE_1.0 |
|
||||
| o4-mini | ❌ | ✅ | USE_1.0 |
|
||||
| o4-mini-2025-04-16 | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5 | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5-2025-08-07 | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5-mini | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5-mini-2025-08-07 | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5-nano | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5-nano-2025-08-07 | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5.1-chat-latest | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5.2-chat-latest | ❌ | ✅ | USE_1.0 |
|
||||
| gpt-5.3-chat-latest | ❌ | ✅ | USE_1.0 |
|
||||
|
||||
### Models Accepting Flexible Temperature (0.7 works)
|
||||
|
||||
All standard GPT models accept flexible temperature values:
|
||||
- gpt-3.5-turbo (all variants)
|
||||
- gpt-4 (all variants)
|
||||
- gpt-4-turbo (all variants)
|
||||
- gpt-4o (all variants)
|
||||
- gpt-4o-mini (all variants)
|
||||
- gpt-4.1 (all variants)
|
||||
- gpt-5-chat-latest
|
||||
- gpt-5.2, gpt-5.2-2025-12-11
|
||||
- gpt-5.4, gpt-5.4-2026-03-05
|
||||
|
||||
### Models Requiring Temperature Omission
|
||||
|
||||
Search-preview models do not accept temperature parameter:
|
||||
- gpt-4o-mini-search-preview
|
||||
- gpt-4o-search-preview
|
||||
- gpt-5-search-api
|
||||
|
||||
## Implementation
|
||||
|
||||
The `adjust_temperature_for_model()` function in `src/providers/openai.rs` automatically adjusts temperature to 1.0 for reasoning models while preserving user-specified values for standard models.
|
||||
|
||||
## Testing Methodology
|
||||
|
||||
Models were tested with:
|
||||
1. No temperature parameter (baseline)
|
||||
2. temperature=0.7 (common default)
|
||||
3. temperature=1.0 (reasoning model requirement)
|
||||
|
||||
Results were validated against actual OpenAI API responses.
|
||||
|
||||
## References
|
||||
|
||||
- OpenAI API Documentation: https://platform.openai.com/docs/api-reference/chat
|
||||
- Related Issue: Temperature errors with o1/o3/gpt-5 models
|
||||
@@ -22,6 +22,64 @@ For first-time installation, start from [one-click-bootstrap.md](../setup-guides
|
||||
| Foreground runtime | `zeroclaw daemon` | local debugging, short-lived sessions |
|
||||
| Foreground gateway only | `zeroclaw gateway` | webhook endpoint testing |
|
||||
| User service | `zeroclaw service install && zeroclaw service start` | persistent operator-managed runtime |
|
||||
| Docker / Podman | `docker compose up -d` | containerized deployment |
|
||||
|
||||
## Docker / Podman Runtime
|
||||
|
||||
If you installed via `./install.sh --docker`, the container exits after onboarding. To run
|
||||
ZeroClaw as a long-lived container, use the repository `docker-compose.yml` or start a
|
||||
container manually against the persisted data directory.
|
||||
|
||||
### Recommended: docker-compose
|
||||
|
||||
```bash
|
||||
# Start (detached, auto-restarts on reboot)
|
||||
docker compose up -d
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Restart
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Replace `docker` with `podman` if using Podman.
|
||||
|
||||
### Manual container lifecycle
|
||||
|
||||
```bash
|
||||
# Start a new container from the bootstrap image
|
||||
docker run -d --name zeroclaw \
|
||||
--restart unless-stopped \
|
||||
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw" \
|
||||
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace" \
|
||||
-e HOME=/zeroclaw-data \
|
||||
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
|
||||
-p 42617:42617 \
|
||||
zeroclaw-bootstrap:local \
|
||||
gateway
|
||||
|
||||
# Stop (preserves config and workspace)
|
||||
docker stop zeroclaw
|
||||
|
||||
# Restart a stopped container
|
||||
docker start zeroclaw
|
||||
|
||||
# View logs
|
||||
docker logs -f zeroclaw
|
||||
|
||||
# Health check
|
||||
docker exec zeroclaw zeroclaw status
|
||||
```
|
||||
|
||||
For Podman, add `--userns keep-id --user "$(id -u):$(id -g)"` and append `:Z` to volume mounts.
|
||||
|
||||
### Key detail: do not re-run install.sh to restart
|
||||
|
||||
Re-running `install.sh --docker` rebuilds the image and re-runs onboarding. To simply
|
||||
restart, use `docker start`, `docker compose up -d`, or `podman start`.
|
||||
|
||||
For full setup instructions, see [one-click-bootstrap.md](../setup-guides/one-click-bootstrap.md#stopping-and-restarting-a-dockerpodman-container).
|
||||
|
||||
## Baseline Operator Checklist
|
||||
|
||||
|
||||
@@ -98,6 +98,103 @@ If you add `--skip-build`, the installer skips local image build. It first tries
|
||||
Docker tag (`ZEROCLAW_DOCKER_IMAGE`, default: `zeroclaw-bootstrap:local`); if missing,
|
||||
it pulls `ghcr.io/zeroclaw-labs/zeroclaw:latest` and tags it locally before running.
|
||||
|
||||
### Stopping and restarting a Docker/Podman container
|
||||
|
||||
After `./install.sh --docker` finishes, the container exits. Your config and workspace
|
||||
are persisted in the data directory (default: `./.zeroclaw-docker`, or `~/.zeroclaw-docker`
|
||||
when bootstrapping via `curl | bash`). You can override this path with `ZEROCLAW_DOCKER_DATA_DIR`.
|
||||
|
||||
**Do not re-run `install.sh`** to restart -- it will rebuild the image and re-run onboarding.
|
||||
Instead, start a new container from the existing image and mount the persisted data directory.
|
||||
|
||||
#### Using the repository docker-compose.yml
|
||||
|
||||
The simplest way to run ZeroClaw long-term in Docker/Podman is with the provided
|
||||
`docker-compose.yml` at the repository root. It uses a named volume (`zeroclaw-data`)
|
||||
and sets `restart: unless-stopped` so the container survives reboots.
|
||||
|
||||
```bash
|
||||
# Start (detached)
|
||||
docker compose up -d
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Restart after stopping
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Replace `docker` with `podman` if you use Podman.
|
||||
|
||||
#### Manual container run (using install.sh data directory)
|
||||
|
||||
If you installed via `./install.sh --docker` and want to reuse the `.zeroclaw-docker`
|
||||
data directory without compose:
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker run -d --name zeroclaw \
|
||||
--restart unless-stopped \
|
||||
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw" \
|
||||
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace" \
|
||||
-e HOME=/zeroclaw-data \
|
||||
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
|
||||
-p 42617:42617 \
|
||||
zeroclaw-bootstrap:local \
|
||||
gateway
|
||||
|
||||
# Podman (add --userns keep-id and :Z volume labels)
|
||||
podman run -d --name zeroclaw \
|
||||
--restart unless-stopped \
|
||||
--userns keep-id \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw:Z" \
|
||||
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace:Z" \
|
||||
-e HOME=/zeroclaw-data \
|
||||
-e ZEROCLAW_WORKSPACE=/zeroclaw-data/workspace \
|
||||
-p 42617:42617 \
|
||||
zeroclaw-bootstrap:local \
|
||||
gateway
|
||||
```
|
||||
|
||||
#### Common lifecycle commands
|
||||
|
||||
```bash
|
||||
# Stop the container (preserves data)
|
||||
docker stop zeroclaw
|
||||
|
||||
# Start a stopped container (config and workspace are intact)
|
||||
docker start zeroclaw
|
||||
|
||||
# View logs
|
||||
docker logs -f zeroclaw
|
||||
|
||||
# Remove the container (data in volumes/.zeroclaw-docker is preserved)
|
||||
docker rm zeroclaw
|
||||
|
||||
# Check health
|
||||
docker exec zeroclaw zeroclaw status
|
||||
```
|
||||
|
||||
#### Environment variables
|
||||
|
||||
When running manually, pass provider configuration as environment variables
|
||||
or ensure they are already saved in the persisted `config.toml`:
|
||||
|
||||
```bash
|
||||
docker run -d --name zeroclaw \
|
||||
-e API_KEY="sk-..." \
|
||||
-e PROVIDER="openrouter" \
|
||||
-v "$PWD/.zeroclaw-docker/.zeroclaw:/zeroclaw-data/.zeroclaw" \
|
||||
-v "$PWD/.zeroclaw-docker/workspace:/zeroclaw-data/workspace" \
|
||||
-p 42617:42617 \
|
||||
zeroclaw-bootstrap:local \
|
||||
gateway
|
||||
```
|
||||
|
||||
If you already ran `onboard` during the initial install, your API key and provider are
|
||||
saved in `.zeroclaw-docker/.zeroclaw/config.toml` and do not need to be passed again.
|
||||
|
||||
### Quick onboarding (non-interactive)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
# LinkedIn Tool — Design Spec
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Risk tier:** Medium (new tool, external API, credential handling)
|
||||
|
||||
## Summary
|
||||
|
||||
Native LinkedIn integration tool for ZeroClaw. Enables the agent to create posts,
|
||||
list its own posts, comment, react, delete posts, view post engagement, and retrieve
|
||||
profile info — all through LinkedIn's official REST API with OAuth2 authentication.
|
||||
|
||||
## Motivation
|
||||
|
||||
Enable ZeroClaw to autonomously publish LinkedIn content on a schedule (via cron),
|
||||
drawing from the user's memory, project history, and Medium feed. Removes dependency
|
||||
on third-party platforms like Composio for social media posting.
|
||||
|
||||
## Required OAuth2 scopes
|
||||
|
||||
Users must grant these scopes when creating their LinkedIn Developer App:
|
||||
|
||||
| Scope | Required for |
|
||||
|---|---|
|
||||
| `w_member_social` | `create_post`, `comment`, `react`, `delete_post` |
|
||||
| `r_liteprofile` | `get_profile` |
|
||||
| `r_member_social` | `list_posts`, `get_engagement` |
|
||||
|
||||
The "Share on LinkedIn" and "Sign In with LinkedIn using OpenID Connect" products
|
||||
must be requested in the LinkedIn Developer App dashboard (both auto-approve).
|
||||
|
||||
## Architecture
|
||||
|
||||
### File structure
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/tools/linkedin.rs` | `Tool` trait impl, action dispatch, parameter validation |
|
||||
| `src/tools/linkedin_client.rs` | OAuth2 token management, LinkedIn REST API wrappers |
|
||||
| `src/tools/mod.rs` | Module declaration, pub use, registration in `all_tools_with_runtime` |
|
||||
| `src/config/schema.rs` | `[linkedin]` config section (`LinkedInConfig`) |
|
||||
| `src/config/mod.rs` | Add `LinkedInConfig` to pub use exports |
|
||||
|
||||
### No new dependencies
|
||||
|
||||
All required crates are already in `Cargo.toml`: `reqwest` (HTTP), `serde`/`serde_json`
|
||||
(serialization), `chrono` (timestamps), `tokio` (async fs for .env reading).
|
||||
|
||||
## Config
|
||||
|
||||
### `config.toml`
|
||||
|
||||
```toml
|
||||
[linkedin]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
### `.env` credentials
|
||||
|
||||
```bash
|
||||
LINKEDIN_CLIENT_ID=your_client_id
|
||||
LINKEDIN_CLIENT_SECRET=your_client_secret
|
||||
LINKEDIN_ACCESS_TOKEN=your_access_token
|
||||
LINKEDIN_REFRESH_TOKEN=your_refresh_token
|
||||
LINKEDIN_PERSON_ID=your_person_urn_id
|
||||
```
|
||||
|
||||
Token format: `LINKEDIN_PERSON_ID` is the bare ID (e.g., `dXNlcjpA...`), not the
|
||||
full URN. The client prefixes `urn:li:person:` internally.
|
||||
|
||||
## Tool design
|
||||
|
||||
### Single tool, action-dispatched
|
||||
|
||||
Tool name: `linkedin`
|
||||
|
||||
The LLM calls it with an `action` field and action-specific parameters:
|
||||
|
||||
```json
|
||||
{ "action": "create_post", "text": "...", "visibility": "PUBLIC" }
|
||||
```
|
||||
|
||||
### Actions
|
||||
|
||||
| Action | Params | API | Write? |
|
||||
|---|---|---|---|
|
||||
| `create_post` | `text`, `visibility?` (PUBLIC/CONNECTIONS, default PUBLIC), `article_url?`, `article_title?` | `POST /rest/posts` | Yes |
|
||||
| `list_posts` | `count?` (default 10, max 50) | `GET /rest/posts?author={personUrn}&q=author` | No |
|
||||
| `comment` | `post_id`, `text` | `POST /rest/socialActions/{id}/comments` | Yes |
|
||||
| `react` | `post_id`, `reaction_type` (LIKE/CELEBRATE/SUPPORT/LOVE/INSIGHTFUL/FUNNY) | `POST /rest/reactions?actor={actorUrn}` | Yes |
|
||||
| `delete_post` | `post_id` | `DELETE /rest/posts/{id}` | Yes |
|
||||
| `get_engagement` | `post_id` | `GET /rest/socialActions/{id}` | No |
|
||||
| `get_profile` | (none) | `GET /rest/me` | No |
|
||||
|
||||
Note: `list_posts` queries posts authored by the authenticated user (not a home feed —
|
||||
LinkedIn does not expose a home feed API). `get_engagement` returns likes/comments/shares
|
||||
counts for a specific post via the socialActions endpoint.
|
||||
|
||||
### Security enforcement
|
||||
|
||||
- Write actions (`create_post`, `comment`, `react`, `delete_post`): check `security.can_act()` + `security.record_action()`
|
||||
- Read actions (`list_posts`, `get_engagement`, `get_profile`): still call `record_action()` for rate tracking
|
||||
|
||||
### Parameter validation
|
||||
|
||||
- `article_title` without `article_url` returns error: "article_title requires article_url"
|
||||
- `react` requires both `post_id` and `reaction_type`
|
||||
- `comment` requires both `post_id` and `text`
|
||||
- `create_post` requires `text` (non-empty)
|
||||
|
||||
### Parameter schema
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create_post", "list_posts", "comment", "react", "delete_post", "get_engagement", "get_profile"],
|
||||
"description": "The LinkedIn action to perform"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Post or comment text content"
|
||||
},
|
||||
"visibility": {
|
||||
"type": "string",
|
||||
"enum": ["PUBLIC", "CONNECTIONS"],
|
||||
"description": "Post visibility (default: PUBLIC)"
|
||||
},
|
||||
"article_url": {
|
||||
"type": "string",
|
||||
"description": "URL to attach as article/link preview"
|
||||
},
|
||||
"article_title": {
|
||||
"type": "string",
|
||||
"description": "Title for the attached article (requires article_url)"
|
||||
},
|
||||
"post_id": {
|
||||
"type": "string",
|
||||
"description": "LinkedIn post URN for comment/react/delete/engagement"
|
||||
},
|
||||
"reaction_type": {
|
||||
"type": "string",
|
||||
"enum": ["LIKE", "CELEBRATE", "SUPPORT", "LOVE", "INSIGHTFUL", "FUNNY"],
|
||||
"description": "Reaction type for the react action"
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of posts to retrieve (default 10, max 50)"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
```
|
||||
|
||||
## LinkedIn client
|
||||
|
||||
### `LinkedInClient` struct
|
||||
|
||||
```rust
|
||||
pub struct LinkedInClient {
|
||||
workspace_dir: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
Uses `crate::config::build_runtime_proxy_client_with_timeouts("tool.linkedin", 30, 10)`
|
||||
per request (same pattern as Pushover), respecting runtime proxy configuration.
|
||||
|
||||
### Credential loading
|
||||
|
||||
Same pattern as `PushoverTool`: reads `.env` from `workspace_dir`, parses key-value
|
||||
pairs, supports `export` prefix and quoted values.
|
||||
|
||||
### Token refresh
|
||||
|
||||
1. All API calls use `LINKEDIN_ACCESS_TOKEN` in `Authorization: Bearer` header
|
||||
2. On 401 response, attempt token refresh:
|
||||
- `POST https://www.linkedin.com/oauth/v2/accessToken`
|
||||
- Body: `grant_type=refresh_token&refresh_token=...&client_id=...&client_secret=...`
|
||||
3. On successful refresh, update `LINKEDIN_ACCESS_TOKEN` in `.env` file via
|
||||
line-targeted replacement (read all lines, replace the matching key line, write back).
|
||||
Preserves `export` prefixes, quoting style, comments, and all other keys.
|
||||
4. Retry the original request once
|
||||
5. If refresh also fails, return error with clear message about re-authentication
|
||||
|
||||
### API versioning
|
||||
|
||||
All requests include:
|
||||
- `LinkedIn-Version: 202402` header (stable version)
|
||||
- `X-Restli-Protocol-Version: 2.0.0` header
|
||||
- `Content-Type: application/json`
|
||||
|
||||
### React endpoint details
|
||||
|
||||
The `react` action sends:
|
||||
- `POST /rest/reactions?actor=urn:li:person:{personId}`
|
||||
- Body: `{"reactionType": "LIKE", "object": "urn:li:ugcPost:{postId}"}`
|
||||
|
||||
The actor URN is derived from `LINKEDIN_PERSON_ID` in `.env`.
|
||||
|
||||
### Response parsing
|
||||
|
||||
The client returns structured data types:
|
||||
|
||||
```rust
|
||||
pub struct PostSummary {
|
||||
pub id: String,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
pub visibility: String,
|
||||
}
|
||||
|
||||
pub struct ProfileInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub headline: String,
|
||||
}
|
||||
|
||||
pub struct EngagementSummary {
|
||||
pub likes: u64,
|
||||
pub comments: u64,
|
||||
pub shares: u64,
|
||||
}
|
||||
```
|
||||
|
||||
## Registration
|
||||
|
||||
In `src/tools/mod.rs` (follows `security_ops` config-gated pattern):
|
||||
|
||||
```rust
|
||||
// Module declarations
|
||||
pub mod linkedin;
|
||||
pub mod linkedin_client;
|
||||
|
||||
// Re-exports
|
||||
pub use linkedin::LinkedInTool;
|
||||
|
||||
// In all_tools_with_runtime():
|
||||
if root_config.linkedin.enabled {
|
||||
tool_arcs.push(Arc::new(LinkedInTool::new(
|
||||
security.clone(),
|
||||
workspace_dir.to_path_buf(),
|
||||
)));
|
||||
}
|
||||
```
|
||||
|
||||
## Config schema
|
||||
|
||||
In `src/config/schema.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct LinkedInConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for LinkedInConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: false }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Added as field `pub linkedin: LinkedInConfig` on the `Config` struct.
|
||||
Added to `pub use` exports in `src/config/mod.rs`.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests (in `linkedin.rs`)
|
||||
|
||||
- Tool name, description, schema validation
|
||||
- Action dispatch routes correctly
|
||||
- Write actions blocked in read-only mode
|
||||
- Write actions blocked by rate limiting
|
||||
- Missing required params return clear errors
|
||||
- Unknown action returns error
|
||||
- `article_title` without `article_url` returns validation error
|
||||
|
||||
### Unit tests (in `linkedin_client.rs`)
|
||||
|
||||
- Credential parsing from `.env` (plain, quoted, export prefix, comments)
|
||||
- Missing credential fields produce specific errors
|
||||
- Token refresh writes updated token back to `.env` preserving other keys
|
||||
- Post creation builds correct request body with URN formatting
|
||||
- React builds correct query param with actor URN
|
||||
- Visibility defaults to PUBLIC when omitted
|
||||
|
||||
### Registry tests (in `mod.rs`)
|
||||
|
||||
- `all_tools` excludes `linkedin` when `linkedin.enabled = false`
|
||||
- `all_tools` includes `linkedin` when `linkedin.enabled = true`
|
||||
|
||||
### Integration tests
|
||||
|
||||
Not added in this PR — would require live LinkedIn API credentials.
|
||||
A `#[cfg(feature = "test-linkedin-live")]` gate can be added later.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Missing `.env` file: "LinkedIn credentials not found. Add LINKEDIN_* keys to .env"
|
||||
- Missing specific key: "LINKEDIN_ACCESS_TOKEN not found in .env"
|
||||
- Expired token + no refresh token: "LinkedIn token expired. Re-authenticate or add LINKEDIN_REFRESH_TOKEN to .env"
|
||||
- `article_title` without `article_url`: "article_title requires article_url to be set"
|
||||
- API errors: pass through LinkedIn's error message with status code
|
||||
- Rate limited by LinkedIn: "LinkedIn API rate limit exceeded. Try again later."
|
||||
- Missing scope: "LinkedIn API returned 403. Ensure your app has the required scopes: w_member_social, r_liteprofile, r_member_social"
|
||||
|
||||
## PR metadata
|
||||
|
||||
- **Branch:** `feature/linkedin-tool`
|
||||
- **Title:** `feat(tools): add native LinkedIn integration tool`
|
||||
- **Risk:** Medium — new tool, external API, no security boundary changes
|
||||
- **Size target:** M (2 new files ~200-300 lines each, 3-4 modified files)
|
||||
+67
-6
@@ -177,11 +177,29 @@ get_available_disk_mb() {
|
||||
fi
|
||||
}
|
||||
|
||||
is_musl_linux() {
|
||||
[[ "$(uname -s)" == "Linux" ]] || return 1
|
||||
|
||||
if [[ -f /etc/alpine-release ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if have_cmd ldd && ldd --version 2>&1 | grep -qi 'musl'; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_release_target() {
|
||||
local os arch
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
if is_musl_linux; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$os:$arch" in
|
||||
Linux:x86_64)
|
||||
echo "x86_64-unknown-linux-gnu"
|
||||
@@ -283,6 +301,12 @@ install_prebuilt_binary() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if is_musl_linux; then
|
||||
warn "Pre-built release binaries are not published for musl/Alpine yet."
|
||||
warn "Falling back to source build."
|
||||
return 1
|
||||
fi
|
||||
|
||||
target="$(detect_release_target || true)"
|
||||
if [[ -z "$target" ]]; then
|
||||
warn "No pre-built binary target mapping for $(uname -s)/$(uname -m)."
|
||||
@@ -517,7 +541,7 @@ install_system_deps() {
|
||||
fi
|
||||
elif have_cmd apt-get; then
|
||||
run_privileged apt-get update -qq
|
||||
run_privileged apt-get install -y build-essential pkg-config git curl
|
||||
run_privileged apt-get install -y build-essential pkg-config git curl libssl-dev
|
||||
elif have_cmd dnf; then
|
||||
run_privileged dnf install -y \
|
||||
gcc \
|
||||
@@ -1145,7 +1169,11 @@ if [[ "$FORCE_SOURCE_BUILD" == false ]]; then
|
||||
SKIP_BUILD=true
|
||||
SKIP_INSTALL=true
|
||||
elif [[ "$PREBUILT_ONLY" == true ]]; then
|
||||
error "Pre-built-only mode requested, but no compatible release asset is available."
|
||||
if is_musl_linux; then
|
||||
error "Pre-built-only mode is not supported on musl/Alpine because releases do not include musl assets yet."
|
||||
else
|
||||
error "Pre-built-only mode requested, but no compatible release asset is available."
|
||||
fi
|
||||
error "Try again later, or run with --force-source-build on a machine with enough RAM/disk."
|
||||
exit 1
|
||||
else
|
||||
@@ -1212,17 +1240,23 @@ if [[ "$SKIP_INSTALL" == false ]]; then
|
||||
|
||||
cargo install --path "$WORK_DIR" --force --locked
|
||||
step_ok "ZeroClaw installed"
|
||||
|
||||
# Sync binary to ~/.local/bin so PATH lookups find the fresh version
|
||||
if [[ -d "$HOME/.local/bin" ]]; then
|
||||
cp -f "$HOME/.cargo/bin/zeroclaw" "$HOME/.local/bin/zeroclaw" 2>/dev/null && \
|
||||
step_ok "Synced binary to ~/.local/bin" || true
|
||||
fi
|
||||
else
|
||||
step_dot "Skipping install"
|
||||
fi
|
||||
|
||||
ZEROCLAW_BIN=""
|
||||
if have_cmd zeroclaw; then
|
||||
ZEROCLAW_BIN="zeroclaw"
|
||||
elif [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
|
||||
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]]; then
|
||||
ZEROCLAW_BIN="$HOME/.cargo/bin/zeroclaw"
|
||||
elif [[ -x "$WORK_DIR/target/release/zeroclaw" ]]; then
|
||||
ZEROCLAW_BIN="$WORK_DIR/target/release/zeroclaw"
|
||||
elif have_cmd zeroclaw; then
|
||||
ZEROCLAW_BIN="zeroclaw"
|
||||
fi
|
||||
|
||||
echo
|
||||
@@ -1282,6 +1316,26 @@ if [[ -n "$ZEROCLAW_BIN" ]]; then
|
||||
step_ok "Gateway service installed"
|
||||
if "$ZEROCLAW_BIN" service restart 2>/dev/null; then
|
||||
step_ok "Gateway service restarted"
|
||||
|
||||
# Fetch and display pairing code from running gateway
|
||||
PAIR_CODE=""
|
||||
for i in 1 2 3 4 5; do
|
||||
sleep 2
|
||||
if PAIR_CODE=$("$ZEROCLAW_BIN" gateway get-paircode 2>/dev/null | grep -oE '[0-9]{6}'); then
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -n "$PAIR_CODE" ]]; then
|
||||
echo
|
||||
echo -e " ${BOLD_BLUE}🔐 Gateway Pairing Code${RESET}"
|
||||
echo
|
||||
echo -e " ${BOLD_BLUE}┌──────────────┐${RESET}"
|
||||
echo -e " ${BOLD_BLUE}│${RESET} ${BOLD}${PAIR_CODE}${RESET} ${BOLD_BLUE}│${RESET}"
|
||||
echo -e " ${BOLD_BLUE}└──────────────┘${RESET}"
|
||||
echo
|
||||
echo -e " ${DIM}Enter this code in the dashboard to pair your device.${RESET}"
|
||||
echo -e " ${DIM}Run 'zeroclaw gateway get-paircode --new' anytime to generate a fresh code.${RESET}"
|
||||
fi
|
||||
else
|
||||
step_fail "Gateway service restart failed — re-run with zeroclaw service start"
|
||||
fi
|
||||
@@ -1312,6 +1366,13 @@ else
|
||||
echo -e "${BOLD_BLUE}${CRAB} ZeroClaw installed successfully!${RESET}"
|
||||
fi
|
||||
|
||||
if [[ -x "$HOME/.cargo/bin/zeroclaw" ]] && ! have_cmd zeroclaw; then
|
||||
echo
|
||||
warn "zeroclaw is installed in $HOME/.cargo/bin, but that directory is not in PATH for this shell."
|
||||
warn 'Run: export PATH="$HOME/.cargo/bin:$PATH"'
|
||||
step_dot "To persist it, add that export line to ~/.bashrc, ~/.zshrc, or your shell profile, then open a new shell."
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_MODE" == "upgrade" ]]; then
|
||||
step_dot "Upgrade complete"
|
||||
fi
|
||||
@@ -1321,7 +1382,7 @@ GATEWAY_PORT=42617
|
||||
DASHBOARD_URL="http://127.0.0.1:${GATEWAY_PORT}"
|
||||
echo
|
||||
echo -e "${BOLD}Dashboard URL:${RESET} ${BLUE}${DASHBOARD_URL}${RESET}"
|
||||
echo -e "${DIM} Enter the pairing code shown above to connect.${RESET}"
|
||||
echo -e "${DIM} Run 'zeroclaw gateway get-paircode' to get your pairing code.${RESET}"
|
||||
|
||||
# --- Copy to clipboard ---
|
||||
COPIED_TO_CLIPBOARD=false
|
||||
|
||||
+335
-4
@@ -33,10 +33,13 @@ pub struct Agent {
|
||||
skills: Vec<crate::skills::Skill>,
|
||||
skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
|
||||
auto_save: bool,
|
||||
memory_session_id: Option<String>,
|
||||
history: Vec<ConversationMessage>,
|
||||
classification_config: crate::config::QueryClassificationConfig,
|
||||
available_hints: Vec<String>,
|
||||
route_model_by_hint: HashMap<String, String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
}
|
||||
|
||||
pub struct AgentBuilder {
|
||||
@@ -55,9 +58,12 @@ pub struct AgentBuilder {
|
||||
skills: Option<Vec<crate::skills::Skill>>,
|
||||
skills_prompt_mode: Option<crate::config::SkillsPromptInjectionMode>,
|
||||
auto_save: Option<bool>,
|
||||
memory_session_id: Option<String>,
|
||||
classification_config: Option<crate::config::QueryClassificationConfig>,
|
||||
available_hints: Option<Vec<String>>,
|
||||
route_model_by_hint: Option<HashMap<String, String>>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
response_cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
}
|
||||
|
||||
impl AgentBuilder {
|
||||
@@ -78,9 +84,12 @@ impl AgentBuilder {
|
||||
skills: None,
|
||||
skills_prompt_mode: None,
|
||||
auto_save: None,
|
||||
memory_session_id: None,
|
||||
classification_config: None,
|
||||
available_hints: None,
|
||||
route_model_by_hint: None,
|
||||
allowed_tools: None,
|
||||
response_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +171,11 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn memory_session_id(mut self, memory_session_id: Option<String>) -> Self {
|
||||
self.memory_session_id = memory_session_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn classification_config(
|
||||
mut self,
|
||||
classification_config: crate::config::QueryClassificationConfig,
|
||||
@@ -180,10 +194,27 @@ impl AgentBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn allowed_tools(mut self, allowed_tools: Option<Vec<String>>) -> Self {
|
||||
self.allowed_tools = allowed_tools;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn response_cache(
|
||||
mut self,
|
||||
cache: Option<Arc<crate::memory::response_cache::ResponseCache>>,
|
||||
) -> Self {
|
||||
self.response_cache = cache;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> Result<Agent> {
|
||||
let tools = self
|
||||
let mut tools = self
|
||||
.tools
|
||||
.ok_or_else(|| anyhow::anyhow!("tools are required"))?;
|
||||
let allowed = self.allowed_tools.clone();
|
||||
if let Some(ref allow_list) = allowed {
|
||||
tools.retain(|t| allow_list.iter().any(|name| name == t.name()));
|
||||
}
|
||||
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
|
||||
|
||||
Ok(Agent {
|
||||
@@ -219,10 +250,13 @@ impl AgentBuilder {
|
||||
skills: self.skills.unwrap_or_default(),
|
||||
skills_prompt_mode: self.skills_prompt_mode.unwrap_or_default(),
|
||||
auto_save: self.auto_save.unwrap_or(false),
|
||||
memory_session_id: self.memory_session_id,
|
||||
history: Vec::new(),
|
||||
classification_config: self.classification_config.unwrap_or_default(),
|
||||
available_hints: self.available_hints.unwrap_or_default(),
|
||||
route_model_by_hint: self.route_model_by_hint.unwrap_or_default(),
|
||||
allowed_tools: allowed,
|
||||
response_cache: self.response_cache,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,6 +274,29 @@ impl Agent {
|
||||
self.history.clear();
|
||||
}
|
||||
|
||||
pub fn set_memory_session_id(&mut self, session_id: Option<String>) {
|
||||
self.memory_session_id = session_id;
|
||||
}
|
||||
|
||||
/// Hydrate the agent with prior chat messages (e.g. from a session backend).
|
||||
///
|
||||
/// Ensures a system prompt is prepended if history is empty, then appends all
|
||||
/// non-system messages from the seed. System messages in the seed are skipped
|
||||
/// to avoid duplicating the system prompt.
|
||||
pub fn seed_history(&mut self, messages: &[ChatMessage]) {
|
||||
if self.history.is_empty() {
|
||||
if let Ok(sys) = self.build_system_prompt() {
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::system(sys)));
|
||||
}
|
||||
}
|
||||
for msg in messages {
|
||||
if msg.role != "system" {
|
||||
self.history.push(ConversationMessage::Chat(msg.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_config(config: &Config) -> Result<Self> {
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
@@ -293,13 +350,16 @@ impl Agent {
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514")
|
||||
.to_string();
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider(
|
||||
let provider_runtime_options = providers::provider_runtime_options_from_config(config);
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider_with_options(
|
||||
provider_name,
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
&model_name,
|
||||
&provider_runtime_options,
|
||||
)?;
|
||||
|
||||
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
|
||||
@@ -317,11 +377,25 @@ impl Agent {
|
||||
.collect();
|
||||
let available_hints: Vec<String> = route_model_by_hint.keys().cloned().collect();
|
||||
|
||||
let response_cache = if config.memory.response_cache_enabled {
|
||||
crate::memory::response_cache::ResponseCache::with_hot_cache(
|
||||
&config.workspace_dir,
|
||||
config.memory.response_cache_ttl_minutes,
|
||||
config.memory.response_cache_max_entries,
|
||||
config.memory.response_cache_hot_entries,
|
||||
)
|
||||
.ok()
|
||||
.map(Arc::new)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(tools)
|
||||
.memory(memory)
|
||||
.observer(observer)
|
||||
.response_cache(response_cache)
|
||||
.tool_dispatcher(tool_dispatcher)
|
||||
.memory_loader(Box::new(DefaultMemoryLoader::new(
|
||||
5,
|
||||
@@ -476,13 +550,22 @@ impl Agent {
|
||||
if self.auto_save {
|
||||
let _ = self
|
||||
.memory
|
||||
.store("user_msg", user_message, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
"user_msg",
|
||||
user_message,
|
||||
MemoryCategory::Conversation,
|
||||
self.memory_session_id.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let context = self
|
||||
.memory_loader
|
||||
.load_context(self.memory.as_ref(), user_message)
|
||||
.load_context(
|
||||
self.memory.as_ref(),
|
||||
user_message,
|
||||
self.memory_session_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -500,6 +583,47 @@ impl Agent {
|
||||
|
||||
for _ in 0..self.config.max_tool_iterations {
|
||||
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
|
||||
|
||||
// Response cache: check before LLM call (only for deterministic, text-only prompts)
|
||||
let cache_key = if self.temperature == 0.0 {
|
||||
self.response_cache.as_ref().map(|_| {
|
||||
let last_user = messages
|
||||
.iter()
|
||||
.rfind(|m| m.role == "user")
|
||||
.map(|m| m.content.as_str())
|
||||
.unwrap_or("");
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
.map(|m| m.content.as_str());
|
||||
crate::memory::response_cache::ResponseCache::cache_key(
|
||||
&effective_model,
|
||||
system,
|
||||
last_user,
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
||||
if let Ok(Some(cached)) = cache.get(key) {
|
||||
self.observer.record_event(&ObserverEvent::CacheHit {
|
||||
cache_type: "response".into(),
|
||||
tokens_saved: 0,
|
||||
});
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::assistant(
|
||||
cached.clone(),
|
||||
)));
|
||||
self.trim_history();
|
||||
return Ok(cached);
|
||||
}
|
||||
self.observer.record_event(&ObserverEvent::CacheMiss {
|
||||
cache_type: "response".into(),
|
||||
});
|
||||
}
|
||||
|
||||
let response = match self
|
||||
.provider
|
||||
.chat(
|
||||
@@ -528,6 +652,17 @@ impl Agent {
|
||||
text
|
||||
};
|
||||
|
||||
// Store in response cache (text-only, no tool calls)
|
||||
if let (Some(ref cache), Some(ref key)) = (&self.response_cache, &cache_key) {
|
||||
let token_count = response
|
||||
.usage
|
||||
.as_ref()
|
||||
.and_then(|u| u.output_tokens)
|
||||
.unwrap_or(0);
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let _ = cache.put(key, &effective_model, &final_text, token_count as u32);
|
||||
}
|
||||
|
||||
self.history
|
||||
.push(ConversationMessage::Chat(ChatMessage::assistant(
|
||||
final_text.clone(),
|
||||
@@ -892,4 +1027,200 @@ mod tests {
|
||||
let seen = seen_models.lock();
|
||||
assert_eq!(seen.as_slice(), &["hint:fast".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn from_config_passes_extra_headers_to_custom_provider() {
|
||||
use axum::{http::HeaderMap, routing::post, Json, Router};
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let captured_headers: Arc<std::sync::Mutex<Option<HashMap<String, String>>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
let captured_headers_clone = captured_headers.clone();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/chat/completions",
|
||||
post(
|
||||
move |headers: HeaderMap, Json(_body): Json<serde_json::Value>| {
|
||||
let captured_headers = captured_headers_clone.clone();
|
||||
async move {
|
||||
let collected = headers
|
||||
.iter()
|
||||
.filter_map(|(name, value)| {
|
||||
value
|
||||
.to_str()
|
||||
.ok()
|
||||
.map(|value| (name.as_str().to_string(), value.to_string()))
|
||||
})
|
||||
.collect();
|
||||
*captured_headers.lock().unwrap() = Some(collected);
|
||||
Json(serde_json::json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "hello from mock"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server_handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
let tmp = TempDir::new().expect("temp dir");
|
||||
let workspace_dir = tmp.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace_dir).unwrap();
|
||||
|
||||
let mut config = crate::config::Config::default();
|
||||
config.workspace_dir = workspace_dir;
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config.api_key = Some("test-key".to_string());
|
||||
config.default_provider = Some(format!("custom:http://{addr}"));
|
||||
config.default_model = Some("test-model".to_string());
|
||||
config.memory.backend = "none".to_string();
|
||||
config.memory.auto_save = false;
|
||||
config.extra_headers.insert(
|
||||
"User-Agent".to_string(),
|
||||
"zeroclaw-web-test/1.0".to_string(),
|
||||
);
|
||||
config
|
||||
.extra_headers
|
||||
.insert("X-Title".to_string(), "zeroclaw-web".to_string());
|
||||
|
||||
let mut agent = Agent::from_config(&config).expect("agent from config");
|
||||
let response = agent.turn("hello").await.expect("agent turn");
|
||||
|
||||
assert_eq!(response, "hello from mock");
|
||||
|
||||
let headers = captured_headers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.expect("captured headers");
|
||||
assert_eq!(
|
||||
headers.get("user-agent").map(String::as_str),
|
||||
Some("zeroclaw-web-test/1.0")
|
||||
);
|
||||
assert_eq!(
|
||||
headers.get("x-title").map(String::as_str),
|
||||
Some("zeroclaw-web")
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_allowed_tools_none_keeps_all_tools() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||
.expect("memory creation should succeed with valid config"),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.allowed_tools(None)
|
||||
.build()
|
||||
.expect("agent builder should succeed with valid config");
|
||||
|
||||
assert_eq!(agent.tool_specs.len(), 1);
|
||||
assert_eq!(agent.tool_specs[0].name, "echo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_allowed_tools_some_filters_tools() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||
.expect("memory creation should succeed with valid config"),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.allowed_tools(Some(vec!["nonexistent".to_string()]))
|
||||
.build()
|
||||
.expect("agent builder should succeed with valid config");
|
||||
|
||||
assert!(
|
||||
agent.tool_specs.is_empty(),
|
||||
"No tools should match a non-existent allowlist entry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_history_prepends_system_and_skips_system_from_seed() {
|
||||
let provider = Box::new(MockProvider {
|
||||
responses: Mutex::new(vec![]),
|
||||
});
|
||||
|
||||
let memory_cfg = crate::config::MemoryConfig {
|
||||
backend: "none".into(),
|
||||
..crate::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::memory::create_memory(&memory_cfg, std::path::Path::new("/tmp"), None)
|
||||
.expect("memory creation should succeed with valid config"),
|
||||
);
|
||||
|
||||
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
.tools(vec![Box::new(MockTool)])
|
||||
.memory(mem)
|
||||
.observer(observer)
|
||||
.tool_dispatcher(Box::new(NativeToolDispatcher))
|
||||
.workspace_dir(std::path::PathBuf::from("/tmp"))
|
||||
.build()
|
||||
.expect("agent builder should succeed with valid config");
|
||||
|
||||
let seed = vec![
|
||||
ChatMessage::system("old system prompt"),
|
||||
ChatMessage::user("hello"),
|
||||
ChatMessage::assistant("hi there"),
|
||||
];
|
||||
agent.seed_history(&seed);
|
||||
|
||||
let history = agent.history();
|
||||
// First message should be a freshly built system prompt (not the seed one)
|
||||
assert!(matches!(&history[0], ConversationMessage::Chat(m) if m.role == "system"));
|
||||
// System message from seed should be skipped, so next is user
|
||||
assert!(
|
||||
matches!(&history[1], ConversationMessage::Chat(m) if m.role == "user" && m.content == "hello")
|
||||
);
|
||||
assert!(
|
||||
matches!(&history[2], ConversationMessage::Chat(m) if m.role == "assistant" && m.content == "hi there")
|
||||
);
|
||||
assert_eq!(history.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-12
@@ -128,7 +128,7 @@ impl ToolDispatcher for XmlToolDispatcher {
|
||||
ConversationMessage::Chat(ChatMessage::user(format!("[Tool results]\n{content}")))
|
||||
}
|
||||
|
||||
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String {
|
||||
fn prompt_instructions(&self, _tools: &[Box<dyn Tool>]) -> String {
|
||||
let mut instructions = String::new();
|
||||
instructions.push_str("## Tool Use Protocol\n\n");
|
||||
instructions
|
||||
@@ -136,17 +136,6 @@ impl ToolDispatcher for XmlToolDispatcher {
|
||||
instructions.push_str(
|
||||
"```\n<tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n```\n\n",
|
||||
);
|
||||
instructions.push_str("### Available Tools\n\n");
|
||||
|
||||
for tool in tools {
|
||||
let _ = writeln!(
|
||||
instructions,
|
||||
"- **{}**: {}\n Parameters: `{}`",
|
||||
tool.name(),
|
||||
tool.description(),
|
||||
tool.parameters_schema()
|
||||
);
|
||||
}
|
||||
|
||||
instructions
|
||||
}
|
||||
|
||||
+625
-88
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,12 @@ use std::fmt::Write;
|
||||
|
||||
#[async_trait]
|
||||
pub trait MemoryLoader: Send + Sync {
|
||||
async fn load_context(&self, memory: &dyn Memory, user_message: &str)
|
||||
-> anyhow::Result<String>;
|
||||
async fn load_context(
|
||||
&self,
|
||||
memory: &dyn Memory,
|
||||
user_message: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> anyhow::Result<String>;
|
||||
}
|
||||
|
||||
pub struct DefaultMemoryLoader {
|
||||
@@ -37,8 +41,9 @@ impl MemoryLoader for DefaultMemoryLoader {
|
||||
&self,
|
||||
memory: &dyn Memory,
|
||||
user_message: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
let entries = memory.recall(user_message, self.limit, None).await?;
|
||||
let entries = memory.recall(user_message, self.limit, session_id).await?;
|
||||
if entries.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
@@ -48,6 +53,9 @@ impl MemoryLoader for DefaultMemoryLoader {
|
||||
if memory::is_assistant_autosave_key(&entry.key) {
|
||||
continue;
|
||||
}
|
||||
if memory::should_skip_autosave_content(&entry.content) {
|
||||
continue;
|
||||
}
|
||||
if let Some(score) = entry.score {
|
||||
if score < self.min_relevance_score {
|
||||
continue;
|
||||
@@ -191,7 +199,10 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn default_loader_formats_context() {
|
||||
let loader = DefaultMemoryLoader::default();
|
||||
let context = loader.load_context(&MockMemory, "hello").await.unwrap();
|
||||
let context = loader
|
||||
.load_context(&MockMemory, "hello", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.contains("[Memory context]"));
|
||||
assert!(context.contains("- k: v"));
|
||||
}
|
||||
@@ -222,7 +233,10 @@ mod tests {
|
||||
]),
|
||||
};
|
||||
|
||||
let context = loader.load_context(&memory, "answer style").await.unwrap();
|
||||
let context = loader
|
||||
.load_context(&memory, "answer style", None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(context.contains("user_fact"));
|
||||
assert!(!context.contains("assistant_resp_legacy"));
|
||||
assert!(!context.contains("fabricated detail"));
|
||||
|
||||
+6
-2
@@ -1282,8 +1282,12 @@ fn xml_dispatcher_generates_tool_instructions() {
|
||||
|
||||
assert!(instructions.contains("## Tool Use Protocol"));
|
||||
assert!(instructions.contains("<tool_call>"));
|
||||
assert!(instructions.contains("echo"));
|
||||
assert!(instructions.contains("Echoes the input"));
|
||||
// Tool listing is handled by ToolsSection in prompt.rs, not by the
|
||||
// dispatcher. prompt_instructions() must only emit the protocol envelope.
|
||||
assert!(
|
||||
!instructions.contains("echo"),
|
||||
"dispatcher should not duplicate tool listing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+143
-4
@@ -44,11 +44,18 @@ pub struct ApprovalLogEntry {
|
||||
|
||||
// ── ApprovalManager ──────────────────────────────────────────────
|
||||
|
||||
/// Manages the interactive approval workflow.
|
||||
/// Manages the approval workflow for tool calls.
|
||||
///
|
||||
/// - Checks config-level `auto_approve` / `always_ask` lists
|
||||
/// - Maintains a session-scoped "always" allowlist
|
||||
/// - Records an audit trail of all decisions
|
||||
///
|
||||
/// Two modes:
|
||||
/// - **Interactive** (CLI): tools needing approval trigger a stdin prompt.
|
||||
/// - **Non-interactive** (channels): tools needing approval are auto-denied
|
||||
/// because there is no interactive operator to approve them. `auto_approve`
|
||||
/// policy is still enforced, and `always_ask` / supervised-default tools are
|
||||
/// denied rather than silently allowed.
|
||||
pub struct ApprovalManager {
|
||||
/// Tools that never need approval (from config).
|
||||
auto_approve: HashSet<String>,
|
||||
@@ -56,6 +63,9 @@ pub struct ApprovalManager {
|
||||
always_ask: HashSet<String>,
|
||||
/// Autonomy level from config.
|
||||
autonomy_level: AutonomyLevel,
|
||||
/// When `true`, tools that would require interactive approval are
|
||||
/// auto-denied instead. Used for channel-driven (non-CLI) runs.
|
||||
non_interactive: bool,
|
||||
/// Session-scoped allowlist built from "Always" responses.
|
||||
session_allowlist: Mutex<HashSet<String>>,
|
||||
/// Audit trail of approval decisions.
|
||||
@@ -63,17 +73,40 @@ pub struct ApprovalManager {
|
||||
}
|
||||
|
||||
impl ApprovalManager {
|
||||
/// Create from autonomy config.
|
||||
/// Create an interactive (CLI) approval manager from autonomy config.
|
||||
pub fn from_config(config: &AutonomyConfig) -> Self {
|
||||
Self {
|
||||
auto_approve: config.auto_approve.iter().cloned().collect(),
|
||||
always_ask: config.always_ask.iter().cloned().collect(),
|
||||
autonomy_level: config.level,
|
||||
non_interactive: false,
|
||||
session_allowlist: Mutex::new(HashSet::new()),
|
||||
audit_log: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a non-interactive approval manager for channel-driven runs.
|
||||
///
|
||||
/// Enforces the same `auto_approve` / `always_ask` / supervised policies
|
||||
/// as the CLI manager, but tools that would require interactive approval
|
||||
/// are auto-denied instead of prompting (since there is no operator).
|
||||
pub fn for_non_interactive(config: &AutonomyConfig) -> Self {
|
||||
Self {
|
||||
auto_approve: config.auto_approve.iter().cloned().collect(),
|
||||
always_ask: config.always_ask.iter().cloned().collect(),
|
||||
autonomy_level: config.level,
|
||||
non_interactive: true,
|
||||
session_allowlist: Mutex::new(HashSet::new()),
|
||||
audit_log: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when this manager operates in non-interactive mode
|
||||
/// (i.e. for channel-driven runs where no operator can approve).
|
||||
pub fn is_non_interactive(&self) -> bool {
|
||||
self.non_interactive
|
||||
}
|
||||
|
||||
/// Check whether a tool call requires interactive approval.
|
||||
///
|
||||
/// Returns `true` if the call needs a prompt, `false` if it can proceed.
|
||||
@@ -93,6 +126,15 @@ impl ApprovalManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Channel-driven shell execution is still guarded by the shell tool's
|
||||
// own command allowlist and risk policy. Skipping the outer approval
|
||||
// gate here lets low-risk allowlisted commands (e.g. `ls`) work in
|
||||
// non-interactive channels without silently allowing medium/high-risk
|
||||
// commands.
|
||||
if self.non_interactive && tool_name == "shell" {
|
||||
return false;
|
||||
}
|
||||
|
||||
// auto_approve skips the prompt.
|
||||
if self.auto_approve.contains(tool_name) {
|
||||
return false;
|
||||
@@ -147,8 +189,8 @@ impl ApprovalManager {
|
||||
|
||||
/// Prompt the user on the CLI and return their decision.
|
||||
///
|
||||
/// For non-CLI channels, returns `Yes` automatically (interactive
|
||||
/// approval is only supported on CLI for now).
|
||||
/// Only called for interactive (CLI) managers. Non-interactive managers
|
||||
/// auto-deny in the tool-call loop before reaching this point.
|
||||
pub fn prompt_cli(&self, request: &ApprovalRequest) -> ApprovalResponse {
|
||||
prompt_cli_interactive(request)
|
||||
}
|
||||
@@ -401,6 +443,103 @@ mod tests {
|
||||
assert!(summary.contains("just a string"));
|
||||
}
|
||||
|
||||
// ── non-interactive (channel) mode ────────────────────────
|
||||
|
||||
#[test]
|
||||
fn non_interactive_manager_reports_non_interactive() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
assert!(mgr.is_non_interactive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_manager_reports_interactive() {
|
||||
let mgr = ApprovalManager::from_config(&supervised_config());
|
||||
assert!(!mgr.is_non_interactive());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_auto_approve_tools_skip_approval() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
// auto_approve tools (file_read, memory_recall) should not need approval.
|
||||
assert!(!mgr.needs_approval("file_read"));
|
||||
assert!(!mgr.needs_approval("memory_recall"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_shell_skips_outer_approval_by_default() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&AutonomyConfig::default());
|
||||
assert!(!mgr.needs_approval("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_always_ask_tools_need_approval() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
// always_ask tools (shell) still report as needing approval,
|
||||
// so the tool-call loop will auto-deny them in non-interactive mode.
|
||||
assert!(mgr.needs_approval("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_unknown_tools_need_approval_in_supervised() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
// Unknown tools in supervised mode need approval (will be auto-denied
|
||||
// by the tool-call loop for non-interactive managers).
|
||||
assert!(mgr.needs_approval("file_write"));
|
||||
assert!(mgr.needs_approval("http_request"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_full_autonomy_never_needs_approval() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&full_config());
|
||||
// Full autonomy means no approval needed, even in non-interactive mode.
|
||||
assert!(!mgr.needs_approval("shell"));
|
||||
assert!(!mgr.needs_approval("file_write"));
|
||||
assert!(!mgr.needs_approval("anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_readonly_never_needs_approval() {
|
||||
let config = AutonomyConfig {
|
||||
level: AutonomyLevel::ReadOnly,
|
||||
..AutonomyConfig::default()
|
||||
};
|
||||
let mgr = ApprovalManager::for_non_interactive(&config);
|
||||
// ReadOnly blocks execution elsewhere; approval manager does not prompt.
|
||||
assert!(!mgr.needs_approval("shell"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_session_allowlist_still_works() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
assert!(mgr.needs_approval("file_write"));
|
||||
|
||||
// Simulate an "Always" decision (would come from a prior channel run
|
||||
// if the tool was auto-approved somehow, e.g. via config change).
|
||||
mgr.record_decision(
|
||||
"file_write",
|
||||
&serde_json::json!({"path": "test.txt"}),
|
||||
ApprovalResponse::Always,
|
||||
"telegram",
|
||||
);
|
||||
|
||||
assert!(!mgr.needs_approval("file_write"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_always_ask_overrides_session_allowlist() {
|
||||
let mgr = ApprovalManager::for_non_interactive(&supervised_config());
|
||||
|
||||
mgr.record_decision(
|
||||
"shell",
|
||||
&serde_json::json!({"command": "ls"}),
|
||||
ApprovalResponse::Always,
|
||||
"telegram",
|
||||
);
|
||||
|
||||
// shell is in always_ask, so it still needs approval even after "Always".
|
||||
assert!(mgr.needs_approval("shell"));
|
||||
}
|
||||
|
||||
// ── ApprovalResponse serde ───────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use anyhow::{bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Bluesky channel — polls for mentions via AT Protocol and replies as posts.
|
||||
pub struct BlueskyChannel {
|
||||
handle: String,
|
||||
app_password: String,
|
||||
auth: Mutex<BlueskyAuth>,
|
||||
}
|
||||
|
||||
struct BlueskyAuth {
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
did: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
const BSKY_API_BASE: &str = "https://bsky.social/xrpc";
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateSessionResponse {
|
||||
#[serde(rename = "accessJwt")]
|
||||
access_jwt: String,
|
||||
#[serde(rename = "refreshJwt")]
|
||||
refresh_jwt: String,
|
||||
did: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RefreshSessionResponse {
|
||||
#[serde(rename = "accessJwt")]
|
||||
access_jwt: String,
|
||||
#[serde(rename = "refreshJwt")]
|
||||
refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NotificationListResponse {
|
||||
notifications: Vec<Notification>,
|
||||
cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct Notification {
|
||||
uri: String,
|
||||
cid: String,
|
||||
author: NotificationAuthor,
|
||||
reason: String,
|
||||
record: Option<serde_json::Value>,
|
||||
#[serde(rename = "isRead")]
|
||||
is_read: bool,
|
||||
#[serde(rename = "indexedAt")]
|
||||
indexed_at: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct NotificationAuthor {
|
||||
did: String,
|
||||
handle: String,
|
||||
#[serde(rename = "displayName")]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// AT Protocol record for creating a post.
|
||||
#[derive(Serialize)]
|
||||
struct CreateRecordRequest {
|
||||
repo: String,
|
||||
collection: String,
|
||||
record: PostRecord,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PostRecord {
|
||||
#[serde(rename = "$type")]
|
||||
record_type: String,
|
||||
text: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reply: Option<ReplyRef>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ReplyRef {
|
||||
root: PostRef,
|
||||
parent: PostRef,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PostRef {
|
||||
uri: String,
|
||||
cid: String,
|
||||
}
|
||||
|
||||
impl BlueskyChannel {
|
||||
pub fn new(handle: String, app_password: String) -> Self {
|
||||
Self {
|
||||
handle,
|
||||
app_password,
|
||||
auth: Mutex::new(BlueskyAuth {
|
||||
access_jwt: String::new(),
|
||||
refresh_jwt: String::new(),
|
||||
did: String::new(),
|
||||
expires_at: Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.bluesky")
|
||||
}
|
||||
|
||||
/// Create a new session with handle + app password.
|
||||
async fn create_session(&self) -> Result<()> {
|
||||
let client = self.http_client();
|
||||
let resp = client
|
||||
.post(format!("{BSKY_API_BASE}/com.atproto.server.createSession"))
|
||||
.json(&serde_json::json!({
|
||||
"identifier": self.handle,
|
||||
"password": self.app_password,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
bail!("Bluesky createSession failed ({status}): {body}");
|
||||
}
|
||||
|
||||
let session: CreateSessionResponse = resp.json().await?;
|
||||
let mut auth = self.auth.lock();
|
||||
auth.access_jwt = session.access_jwt;
|
||||
auth.refresh_jwt = session.refresh_jwt;
|
||||
auth.did = session.did;
|
||||
// AT Protocol JWTs typically last ~2 hours; refresh well before that.
|
||||
auth.expires_at = Instant::now() + Duration::from_secs(90 * 60);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refresh an existing session.
|
||||
async fn refresh_session(&self) -> Result<()> {
|
||||
let refresh_jwt = {
|
||||
let auth = self.auth.lock();
|
||||
auth.refresh_jwt.clone()
|
||||
};
|
||||
|
||||
if refresh_jwt.is_empty() {
|
||||
return self.create_session().await;
|
||||
}
|
||||
|
||||
let client = self.http_client();
|
||||
let resp = client
|
||||
.post(format!("{BSKY_API_BASE}/com.atproto.server.refreshSession"))
|
||||
.bearer_auth(&refresh_jwt)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
// Refresh failed — fall back to full re-auth
|
||||
tracing::warn!("Bluesky session refresh failed, re-authenticating");
|
||||
return self.create_session().await;
|
||||
}
|
||||
|
||||
let refreshed: RefreshSessionResponse = resp.json().await?;
|
||||
let mut auth = self.auth.lock();
|
||||
auth.access_jwt = refreshed.access_jwt;
|
||||
auth.refresh_jwt = refreshed.refresh_jwt;
|
||||
auth.expires_at = Instant::now() + Duration::from_secs(90 * 60);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a valid access JWT, refreshing if expired.
|
||||
async fn get_access_jwt(&self) -> Result<String> {
|
||||
{
|
||||
let auth = self.auth.lock();
|
||||
if !auth.access_jwt.is_empty() && Instant::now() < auth.expires_at {
|
||||
return Ok(auth.access_jwt.clone());
|
||||
}
|
||||
}
|
||||
self.refresh_session().await?;
|
||||
let auth = self.auth.lock();
|
||||
Ok(auth.access_jwt.clone())
|
||||
}
|
||||
|
||||
/// Get the DID for the authenticated account.
|
||||
fn get_did(&self) -> String {
|
||||
self.auth.lock().did.clone()
|
||||
}
|
||||
|
||||
/// Parse a notification into a ChannelMessage (only processes mentions).
|
||||
fn parse_notification(&self, notif: &Notification) -> Option<ChannelMessage> {
|
||||
// Only process mentions
|
||||
if notif.reason != "mention" && notif.reason != "reply" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip already-read notifications
|
||||
if notif.is_read {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Skip own posts
|
||||
if notif.author.did == self.get_did() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Extract text from the record
|
||||
let text = notif
|
||||
.record
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse timestamp from indexedAt (ISO 8601)
|
||||
let timestamp = chrono::DateTime::parse_from_rfc3339(¬if.indexed_at)
|
||||
.map(|dt| dt.timestamp().cast_unsigned())
|
||||
.unwrap_or(0);
|
||||
|
||||
// Extract CID from the record for reply references
|
||||
let cid = notif
|
||||
.record
|
||||
.as_ref()
|
||||
.and_then(|r| r.get("cid"))
|
||||
.and_then(|c| c.as_str())
|
||||
.unwrap_or(¬if.cid);
|
||||
|
||||
// The reply target encodes the URI and CID needed for threading
|
||||
let reply_target = format!("{}|{}", notif.uri, cid);
|
||||
|
||||
Some(ChannelMessage {
|
||||
id: format!("bluesky_{}", notif.cid),
|
||||
sender: notif.author.handle.clone(),
|
||||
reply_target,
|
||||
content: text.to_string(),
|
||||
channel: "bluesky".to_string(),
|
||||
timestamp,
|
||||
thread_ts: Some(notif.uri.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark notifications as read up to a given timestamp.
|
||||
async fn update_seen(&self, seen_at: &str) -> Result<()> {
|
||||
let token = self.get_access_jwt().await?;
|
||||
let client = self.http_client();
|
||||
|
||||
let resp = client
|
||||
.post(format!("{BSKY_API_BASE}/app.bsky.notification.updateSeen"))
|
||||
.bearer_auth(&token)
|
||||
.json(&serde_json::json!({ "seenAt": seen_at }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("Bluesky updateSeen failed: {}", resp.status());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for BlueskyChannel {
|
||||
fn name(&self) -> &str {
|
||||
"bluesky"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||
let token = self.get_access_jwt().await?;
|
||||
let did = self.get_did();
|
||||
let client = self.http_client();
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// Parse reply reference from recipient if present (format: "uri|cid")
|
||||
let reply = if message.recipient.contains('|') {
|
||||
let parts: Vec<&str> = message.recipient.splitn(2, '|').collect();
|
||||
if parts.len() == 2 {
|
||||
let uri = parts[0];
|
||||
let cid = parts[1];
|
||||
Some(ReplyRef {
|
||||
root: PostRef {
|
||||
uri: uri.to_string(),
|
||||
cid: cid.to_string(),
|
||||
},
|
||||
parent: PostRef {
|
||||
uri: uri.to_string(),
|
||||
cid: cid.to_string(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Bluesky posts have a 300-character limit (grapheme clusters).
|
||||
// For longer content, truncate with an indicator.
|
||||
let text = if message.content.len() > 300 {
|
||||
format!("{}...", &message.content[..297])
|
||||
} else {
|
||||
message.content.clone()
|
||||
};
|
||||
|
||||
let request = CreateRecordRequest {
|
||||
repo: did,
|
||||
collection: "app.bsky.feed.post".to_string(),
|
||||
record: PostRecord {
|
||||
record_type: "app.bsky.feed.post".to_string(),
|
||||
text,
|
||||
created_at: now,
|
||||
reply,
|
||||
},
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!("{BSKY_API_BASE}/com.atproto.repo.createRecord"))
|
||||
.bearer_auth(&token)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
bail!("Bluesky post failed ({status}): {body}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
// Initial auth
|
||||
self.create_session().await?;
|
||||
|
||||
tracing::info!("Bluesky channel listening as @{}...", self.handle);
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
let token = match self.get_access_jwt().await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::warn!("Bluesky auth error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let client = self.http_client();
|
||||
let resp = match client
|
||||
.get(format!(
|
||||
"{BSKY_API_BASE}/app.bsky.notification.listNotifications"
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.query(&[("limit", "25")])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("Bluesky poll error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("Bluesky notifications failed: {}", resp.status());
|
||||
continue;
|
||||
}
|
||||
|
||||
let listing: NotificationListResponse = match resp.json().await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
tracing::warn!("Bluesky parse error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut latest_indexed_at: Option<String> = None;
|
||||
for notif in &listing.notifications {
|
||||
if let Some(msg) = self.parse_notification(notif) {
|
||||
latest_indexed_at = Some(notif.indexed_at.clone());
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as seen
|
||||
if let Some(ref seen_at) = latest_indexed_at {
|
||||
if let Err(e) = self.update_seen(seen_at).await {
|
||||
tracing::warn!("Bluesky updateSeen error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = &listing.cursor; // cursor available for pagination if needed
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.get_access_jwt().await.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_channel() -> BlueskyChannel {
|
||||
let ch = BlueskyChannel::new("testbot.bsky.social".into(), "app-password".into());
|
||||
// Seed auth with a DID for tests
|
||||
{
|
||||
let mut auth = ch.auth.lock();
|
||||
auth.did = "did:plc:test123".into();
|
||||
}
|
||||
ch
|
||||
}
|
||||
|
||||
fn make_notification(
|
||||
reason: &str,
|
||||
handle: &str,
|
||||
did: &str,
|
||||
text: &str,
|
||||
is_read: bool,
|
||||
) -> Notification {
|
||||
Notification {
|
||||
uri: format!("at://{did}/app.bsky.feed.post/abc123"),
|
||||
cid: "bafyreitest123".into(),
|
||||
author: NotificationAuthor {
|
||||
did: did.into(),
|
||||
handle: handle.into(),
|
||||
display_name: None,
|
||||
},
|
||||
reason: reason.into(),
|
||||
record: Some(serde_json::json!({ "text": text })),
|
||||
is_read,
|
||||
indexed_at: "2026-01-15T10:00:00.000Z".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mention_notification() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification(
|
||||
"mention",
|
||||
"user1.bsky.social",
|
||||
"did:plc:user1",
|
||||
"@testbot hello",
|
||||
false,
|
||||
);
|
||||
|
||||
let msg = ch.parse_notification(¬if).unwrap();
|
||||
assert_eq!(msg.sender, "user1.bsky.social");
|
||||
assert_eq!(msg.content, "@testbot hello");
|
||||
assert_eq!(msg.channel, "bluesky");
|
||||
assert!(msg.id.starts_with("bluesky_"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_reply_notification() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification(
|
||||
"reply",
|
||||
"user2.bsky.social",
|
||||
"did:plc:user2",
|
||||
"thanks for the info!",
|
||||
false,
|
||||
);
|
||||
|
||||
let msg = ch.parse_notification(¬if).unwrap();
|
||||
assert_eq!(msg.sender, "user2.bsky.social");
|
||||
assert_eq!(msg.content, "thanks for the info!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_read_notifications() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification(
|
||||
"mention",
|
||||
"user1.bsky.social",
|
||||
"did:plc:user1",
|
||||
"old message",
|
||||
true,
|
||||
);
|
||||
|
||||
assert!(ch.parse_notification(¬if).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_own_notifications() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification(
|
||||
"mention",
|
||||
"testbot.bsky.social",
|
||||
"did:plc:test123", // same as seeded DID
|
||||
"self message",
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(ch.parse_notification(¬if).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_like_notifications() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification(
|
||||
"like",
|
||||
"user1.bsky.social",
|
||||
"did:plc:user1",
|
||||
"liked post",
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(ch.parse_notification(¬if).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_empty_text() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification("mention", "user1.bsky.social", "did:plc:user1", "", false);
|
||||
|
||||
assert!(ch.parse_notification(¬if).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_target_encoding() {
|
||||
let ch = make_channel();
|
||||
let notif = make_notification(
|
||||
"mention",
|
||||
"user1.bsky.social",
|
||||
"did:plc:user1",
|
||||
"hello",
|
||||
false,
|
||||
);
|
||||
|
||||
let msg = ch.parse_notification(¬if).unwrap();
|
||||
// reply_target should contain URI|CID
|
||||
assert!(msg.reply_target.contains('|'));
|
||||
let parts: Vec<&str> = msg.reply_target.splitn(2, '|').collect();
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert!(parts[0].starts_with("at://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_formatting() {
|
||||
// Verify reply target parsing
|
||||
let reply_target = "at://did:plc:user1/app.bsky.feed.post/abc|bafyreitest";
|
||||
let parts: Vec<&str> = reply_target.splitn(2, '|').collect();
|
||||
assert_eq!(parts.len(), 2);
|
||||
assert_eq!(parts[0], "at://did:plc:user1/app.bsky.feed.post/abc");
|
||||
assert_eq!(parts[1], "bafyreitest");
|
||||
}
|
||||
}
|
||||
+41
-1
@@ -711,8 +711,13 @@ impl Channel for DiscordChannel {
|
||||
}
|
||||
|
||||
let content = d.get("content").and_then(|c| c.as_str()).unwrap_or("");
|
||||
// DMs carry no guild_id in the Discord gateway payload. They are
|
||||
// inherently private and implicitly addressed to the bot, so bypass
|
||||
// the mention gate — requiring a @mention in a DM is never correct.
|
||||
let is_dm = d.get("guild_id").is_none();
|
||||
let effective_mention_only = self.mention_only && !is_dm;
|
||||
let Some(clean_content) =
|
||||
normalize_incoming_content(content, self.mention_only, &bot_user_id)
|
||||
normalize_incoming_content(content, effective_mention_only, &bot_user_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -1027,6 +1032,41 @@ mod tests {
|
||||
assert!(cleaned.is_none());
|
||||
}
|
||||
|
||||
// mention_only DM-bypass tests
|
||||
|
||||
#[test]
|
||||
fn mention_only_dm_bypasses_mention_gate() {
|
||||
// DMs (no guild_id) must pass through even when mention_only is true
|
||||
// and the message contains no @mention. Mirrors the listen call-site logic.
|
||||
let mention_only = true;
|
||||
let is_dm = true;
|
||||
let effective = mention_only && !is_dm;
|
||||
let cleaned = normalize_incoming_content("hello without mention", effective, "12345");
|
||||
assert_eq!(cleaned.as_deref(), Some("hello without mention"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_only_guild_message_without_mention_is_rejected() {
|
||||
// Guild messages (has guild_id, so is_dm = false) must still be rejected
|
||||
// when mention_only is true and the message contains no @mention.
|
||||
let mention_only = true;
|
||||
let is_dm = false;
|
||||
let effective = mention_only && !is_dm;
|
||||
let cleaned = normalize_incoming_content("hello without mention", effective, "12345");
|
||||
assert!(cleaned.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mention_only_guild_message_with_mention_passes_and_strips() {
|
||||
// Guild messages that do carry a @mention pass through and have the
|
||||
// mention tag stripped, consistent with pre-existing behaviour.
|
||||
let mention_only = true;
|
||||
let is_dm = false;
|
||||
let effective = mention_only && !is_dm;
|
||||
let cleaned = normalize_incoming_content("<@12345> run status", effective, "12345");
|
||||
assert_eq!(cleaned.as_deref(), Some("run status"));
|
||||
}
|
||||
|
||||
// Message splitting tests
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -746,7 +746,7 @@ impl Channel for MatrixChannel {
|
||||
MessageType::Notice(content) => (content.body.clone(), None),
|
||||
MessageType::Image(content) => {
|
||||
let dl = media_info(&content.source, &content.body);
|
||||
(format!("[image: {}]", content.body), dl)
|
||||
(format!("[IMAGE:{}]", content.body), dl)
|
||||
}
|
||||
MessageType::File(content) => {
|
||||
let dl = media_info(&content.source, &content.body);
|
||||
@@ -888,7 +888,7 @@ impl Channel for MatrixChannel {
|
||||
sender: sender.clone(),
|
||||
reply_target: format!("{}||{}", sender, room.room_id()),
|
||||
content: body,
|
||||
channel: format!("matrix:{}", room.room_id()),
|
||||
channel: "matrix".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Deduplication set capacity — evict half of entries when full.
|
||||
const DEDUP_CAPACITY: usize = 10_000;
|
||||
|
||||
/// Mochat customer service channel.
|
||||
///
|
||||
/// Integrates with the Mochat open-source customer service platform API
|
||||
/// for receiving and sending messages through its HTTP endpoints.
|
||||
pub struct MochatChannel {
|
||||
api_url: String,
|
||||
api_token: String,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval_secs: u64,
|
||||
/// Message deduplication set.
|
||||
dedup: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl MochatChannel {
|
||||
pub fn new(
|
||||
api_url: String,
|
||||
api_token: String,
|
||||
allowed_users: Vec<String>,
|
||||
poll_interval_secs: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_url: api_url.trim_end_matches('/').to_string(),
|
||||
api_token,
|
||||
allowed_users,
|
||||
poll_interval_secs,
|
||||
dedup: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.mochat")
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||
}
|
||||
|
||||
/// Check and insert message ID for deduplication.
|
||||
async fn is_duplicate(&self, msg_id: &str) -> bool {
|
||||
if msg_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut dedup = self.dedup.write().await;
|
||||
|
||||
if dedup.contains(msg_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if dedup.len() >= DEDUP_CAPACITY {
|
||||
let to_remove: Vec<String> = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect();
|
||||
for key in to_remove {
|
||||
dedup.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
dedup.insert(msg_id.to_string());
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for MochatChannel {
|
||||
fn name(&self) -> &str {
|
||||
"mochat"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
let body = json!({
|
||||
"toUserId": message.recipient,
|
||||
"msgType": "text",
|
||||
"content": {
|
||||
"text": message.content,
|
||||
}
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http_client()
|
||||
.post(format!("{}/api/message/send", self.api_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Mochat send message failed ({status}): {err}");
|
||||
}
|
||||
|
||||
let result: serde_json::Value = resp.json().await?;
|
||||
let code = result.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
|
||||
if code != 0 && code != 200 {
|
||||
let msg = result
|
||||
.get("msg")
|
||||
.or_else(|| result.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown error");
|
||||
anyhow::bail!("Mochat API error (code={code}): {msg}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
tracing::info!("Mochat: starting message poller");
|
||||
|
||||
let poll_interval = std::time::Duration::from_secs(self.poll_interval_secs);
|
||||
let mut last_message_id: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut url = format!("{}/api/message/receive", self.api_url);
|
||||
if let Some(ref id) = last_message_id {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(url, "?since_id={id}");
|
||||
}
|
||||
|
||||
match self
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data: serde_json::Value = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("Mochat: failed to parse response: {e}");
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let messages = data
|
||||
.get("data")
|
||||
.or_else(|| data.get("messages"))
|
||||
.and_then(|d| d.as_array());
|
||||
|
||||
if let Some(messages) = messages {
|
||||
for msg in messages {
|
||||
let msg_id = msg
|
||||
.get("messageId")
|
||||
.or_else(|| msg.get("id"))
|
||||
.and_then(|i| i.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if self.is_duplicate(msg_id).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let sender = msg
|
||||
.get("fromUserId")
|
||||
.or_else(|| msg.get("sender"))
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
if !self.is_user_allowed(sender) {
|
||||
tracing::debug!(
|
||||
"Mochat: ignoring message from unauthorized user: {sender}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = msg
|
||||
.get("content")
|
||||
.and_then(|c| {
|
||||
c.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.or_else(|| c.as_str())
|
||||
})
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: sender.to_string(),
|
||||
reply_target: sender.to_string(),
|
||||
content: content.to_string(),
|
||||
channel: "mochat".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: None,
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
tracing::warn!("Mochat: message channel closed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !msg_id.is_empty() {
|
||||
last_message_id = Some(msg_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!("Mochat: poll request failed ({status}): {err}");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Mochat: poll request error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let resp = self
|
||||
.http_client()
|
||||
.get(format!("{}/api/health", self.api_url))
|
||||
.header("Authorization", format!("Bearer {}", self.api_token))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => r.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name() {
|
||||
let ch = MochatChannel::new("https://mochat.example.com".into(), "tok".into(), vec![], 5);
|
||||
assert_eq!(ch.name(), "mochat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_api_url_trailing_slash_stripped() {
|
||||
let ch = MochatChannel::new(
|
||||
"https://mochat.example.com/".into(),
|
||||
"tok".into(),
|
||||
vec![],
|
||||
5,
|
||||
);
|
||||
assert_eq!(ch.api_url, "https://mochat.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_allowed_wildcard() {
|
||||
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec!["*".into()], 5);
|
||||
assert!(ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_allowed_specific() {
|
||||
let ch = MochatChannel::new(
|
||||
"https://m.test".into(),
|
||||
"tok".into(),
|
||||
vec!["user123".into()],
|
||||
5,
|
||||
);
|
||||
assert!(ch.is_user_allowed("user123"));
|
||||
assert!(!ch.is_user_allowed("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_denied_empty() {
|
||||
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5);
|
||||
assert!(!ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dedup() {
|
||||
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5);
|
||||
assert!(!ch.is_duplicate("msg1").await);
|
||||
assert!(ch.is_duplicate("msg1").await);
|
||||
assert!(!ch.is_duplicate("msg2").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dedup_empty_id() {
|
||||
let ch = MochatChannel::new("https://m.test".into(), "tok".into(), vec![], 5);
|
||||
assert!(!ch.is_duplicate("").await);
|
||||
assert!(!ch.is_duplicate("").await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serde() {
|
||||
let toml_str = r#"
|
||||
api_url = "https://mochat.example.com"
|
||||
api_token = "secret"
|
||||
allowed_users = ["user1"]
|
||||
"#;
|
||||
let config: crate::config::schema::MochatConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.api_url, "https://mochat.example.com");
|
||||
assert_eq!(config.api_token, "secret");
|
||||
assert_eq!(config.allowed_users, vec!["user1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serde_defaults() {
|
||||
let toml_str = r#"
|
||||
api_url = "https://mochat.example.com"
|
||||
api_token = "secret"
|
||||
"#;
|
||||
let config: crate::config::schema::MochatConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(config.allowed_users.is_empty());
|
||||
assert_eq!(config.poll_interval_secs, 5);
|
||||
}
|
||||
}
|
||||
+1035
-20
File diff suppressed because it is too large
Load Diff
+211
-32
@@ -62,24 +62,146 @@ impl NextcloudTalkChannel {
|
||||
|
||||
/// Parse a Nextcloud Talk webhook payload into channel messages.
|
||||
///
|
||||
/// Relevant payload fields:
|
||||
/// - `type` (accepts `message` or `Create`)
|
||||
/// - `object.token` (room token for reply routing)
|
||||
/// - `message.actorType`, `message.actorId`, `message.message`, `message.timestamp`
|
||||
/// Two payload formats are supported:
|
||||
///
|
||||
/// **Format A — legacy/custom** (`type: "message"`):
|
||||
/// ```json
|
||||
/// {
|
||||
/// "type": "message",
|
||||
/// "object": { "token": "<room>" },
|
||||
/// "message": { "actorId": "...", "message": "...", ... }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// **Format B — Activity Streams 2.0** (`type: "Create"`):
|
||||
/// This is the format actually sent by Nextcloud Talk bot webhooks.
|
||||
/// ```json
|
||||
/// {
|
||||
/// "type": "Create",
|
||||
/// "actor": { "type": "Person", "id": "users/alice", "name": "Alice" },
|
||||
/// "object": { "type": "Note", "id": "177", "content": "{\"message\":\"hi\",\"parameters\":[]}", "mediaType": "text/markdown" },
|
||||
/// "target": { "type": "Collection", "id": "<room_token>", "name": "Room Name" }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn parse_webhook_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
|
||||
let messages = Vec::new();
|
||||
|
||||
let event_type = match payload.get("type").and_then(|v| v.as_str()) {
|
||||
Some(t) => t,
|
||||
None => return messages,
|
||||
};
|
||||
|
||||
// Activity Streams 2.0 format sent by Nextcloud Talk bot webhooks.
|
||||
if event_type.eq_ignore_ascii_case("create") {
|
||||
return self.parse_as2_payload(payload);
|
||||
}
|
||||
|
||||
// Legacy/custom format.
|
||||
if !event_type.eq_ignore_ascii_case("message") {
|
||||
tracing::debug!("Nextcloud Talk: skipping non-message event: {event_type}");
|
||||
return messages;
|
||||
}
|
||||
|
||||
self.parse_message_payload(payload)
|
||||
}
|
||||
|
||||
/// Parse Activity Streams 2.0 `Create` payload (real Nextcloud Talk bot webhook format).
|
||||
fn parse_as2_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Some(event_type) = payload.get("type").and_then(|v| v.as_str()) {
|
||||
// Nextcloud Talk bot webhooks send "Create" for new chat messages,
|
||||
// but some setups may use "message". Accept both.
|
||||
let is_message_event = event_type.eq_ignore_ascii_case("message")
|
||||
|| event_type.eq_ignore_ascii_case("create");
|
||||
if !is_message_event {
|
||||
tracing::debug!("Nextcloud Talk: skipping non-message event: {event_type}");
|
||||
return messages;
|
||||
}
|
||||
let obj = match payload.get("object") {
|
||||
Some(o) => o,
|
||||
None => return messages,
|
||||
};
|
||||
|
||||
// Only handle Note objects (= chat messages). Ignore reactions, etc.
|
||||
let object_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if !object_type.eq_ignore_ascii_case("note") {
|
||||
tracing::debug!("Nextcloud Talk: skipping AS2 Create with object.type={object_type}");
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Room token is in target.id.
|
||||
let room_token = payload
|
||||
.get("target")
|
||||
.and_then(|t| t.get("id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty());
|
||||
|
||||
let Some(room_token) = room_token else {
|
||||
tracing::warn!("Nextcloud Talk: missing target.id (room token) in AS2 payload");
|
||||
return messages;
|
||||
};
|
||||
|
||||
// Actor — skip bot-originated messages to prevent feedback loops.
|
||||
let actor = payload.get("actor").cloned().unwrap_or_default();
|
||||
let actor_type = actor.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if actor_type.eq_ignore_ascii_case("application") {
|
||||
tracing::debug!("Nextcloud Talk: skipping bot-originated AS2 message");
|
||||
return messages;
|
||||
}
|
||||
|
||||
// actor.id is "users/<id>" — strip the prefix.
|
||||
let actor_id = actor
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|id| id.trim_start_matches("users/").trim())
|
||||
.filter(|id| !id.is_empty());
|
||||
|
||||
let Some(actor_id) = actor_id else {
|
||||
tracing::warn!("Nextcloud Talk: missing actor.id in AS2 payload");
|
||||
return messages;
|
||||
};
|
||||
|
||||
if !self.is_user_allowed(actor_id) {
|
||||
tracing::warn!(
|
||||
"Nextcloud Talk: ignoring message from unauthorized actor: {actor_id}. \
|
||||
Add to channels.nextcloud_talk.allowed_users in config.toml, \
|
||||
or run `zeroclaw onboard --channels-only` to configure interactively."
|
||||
);
|
||||
return messages;
|
||||
}
|
||||
|
||||
// Message text is JSON-encoded inside object.content.
|
||||
// e.g. content = "{\"message\":\"hello\",\"parameters\":[]}"
|
||||
let content = obj
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| {
|
||||
v.get("message")
|
||||
.and_then(|m| m.as_str())
|
||||
.map(str::trim)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
let Some(content) = content else {
|
||||
tracing::debug!("Nextcloud Talk: empty or unparseable AS2 message content");
|
||||
return messages;
|
||||
};
|
||||
|
||||
let message_id =
|
||||
Self::value_to_string(obj.get("id")).unwrap_or_else(|| Uuid::new_v4().to_string());
|
||||
|
||||
messages.push(ChannelMessage {
|
||||
id: message_id,
|
||||
reply_target: room_token.to_string(),
|
||||
sender: actor_id.to_string(),
|
||||
content,
|
||||
channel: "nextcloud_talk".to_string(),
|
||||
timestamp: Self::now_unix_secs(),
|
||||
thread_ts: None,
|
||||
});
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Parse legacy `type: "message"` payload format.
|
||||
fn parse_message_payload(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
let Some(message_obj) = payload.get("message") else {
|
||||
return messages;
|
||||
};
|
||||
@@ -343,33 +465,90 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nextcloud_talk_parse_create_event_type() {
|
||||
let channel = make_channel();
|
||||
fn nextcloud_talk_parse_as2_create_payload() {
|
||||
let channel = NextcloudTalkChannel::new(
|
||||
"https://cloud.example.com".into(),
|
||||
"app-token".into(),
|
||||
vec!["*".into()],
|
||||
);
|
||||
// Real payload format sent by Nextcloud Talk bot webhooks.
|
||||
let payload = serde_json::json!({
|
||||
"type": "Create",
|
||||
"object": {
|
||||
"id": "42",
|
||||
"token": "room-token-123",
|
||||
"name": "Team Room",
|
||||
"type": "room"
|
||||
"actor": {
|
||||
"type": "Person",
|
||||
"id": "users/user_a",
|
||||
"name": "User A",
|
||||
"talkParticipantType": "1"
|
||||
},
|
||||
"message": {
|
||||
"id": 88,
|
||||
"token": "room-token-123",
|
||||
"actorType": "users",
|
||||
"actorId": "user_a",
|
||||
"actorDisplayName": "User A",
|
||||
"timestamp": 1_735_701_300,
|
||||
"messageType": "comment",
|
||||
"systemMessage": "",
|
||||
"message": "Hello via Create event"
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"id": "177",
|
||||
"name": "message",
|
||||
"content": "{\"message\":\"hallo, bist du da?\",\"parameters\":[]}",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"target": {
|
||||
"type": "Collection",
|
||||
"id": "room-token-123",
|
||||
"name": "HOME"
|
||||
}
|
||||
});
|
||||
|
||||
let messages = channel.parse_webhook_payload(&payload);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].id, "88");
|
||||
assert_eq!(messages[0].content, "Hello via Create event");
|
||||
assert_eq!(messages[0].reply_target, "room-token-123");
|
||||
assert_eq!(messages[0].sender, "user_a");
|
||||
assert_eq!(messages[0].content, "hallo, bist du da?");
|
||||
assert_eq!(messages[0].channel, "nextcloud_talk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nextcloud_talk_parse_as2_skips_bot_originated() {
|
||||
let channel = NextcloudTalkChannel::new(
|
||||
"https://cloud.example.com".into(),
|
||||
"app-token".into(),
|
||||
vec!["*".into()],
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"type": "Create",
|
||||
"actor": {
|
||||
"type": "Application",
|
||||
"id": "bots/jarvis",
|
||||
"name": "jarvis"
|
||||
},
|
||||
"object": {
|
||||
"type": "Note",
|
||||
"id": "178",
|
||||
"content": "{\"message\":\"I am the bot\",\"parameters\":[]}",
|
||||
"mediaType": "text/markdown"
|
||||
},
|
||||
"target": {
|
||||
"type": "Collection",
|
||||
"id": "room-token-123",
|
||||
"name": "HOME"
|
||||
}
|
||||
});
|
||||
|
||||
let messages = channel.parse_webhook_payload(&payload);
|
||||
assert!(messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nextcloud_talk_parse_as2_skips_non_note_objects() {
|
||||
let channel = NextcloudTalkChannel::new(
|
||||
"https://cloud.example.com".into(),
|
||||
"app-token".into(),
|
||||
vec!["*".into()],
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"type": "Create",
|
||||
"actor": { "type": "Person", "id": "users/user_a" },
|
||||
"object": { "type": "Reaction", "id": "5" },
|
||||
"target": { "type": "Collection", "id": "room-token-123" }
|
||||
});
|
||||
|
||||
let messages = channel.parse_webhook_payload(&payload);
|
||||
assert!(messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use anyhow::{bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const NOTION_API_BASE: &str = "https://api.notion.com/v1";
|
||||
const NOTION_VERSION: &str = "2022-06-28";
|
||||
const MAX_RESULT_LENGTH: usize = 2000;
|
||||
const MAX_RETRIES: u32 = 3;
|
||||
const RETRY_BASE_DELAY_MS: u64 = 2000;
|
||||
/// Maximum number of characters to include from an error response body.
|
||||
const MAX_ERROR_BODY_CHARS: usize = 500;
|
||||
|
||||
/// Find the largest byte index <= `max_bytes` that falls on a UTF-8 char boundary.
|
||||
fn floor_utf8_char_boundary(s: &str, max_bytes: usize) -> usize {
|
||||
if max_bytes >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
let mut idx = max_bytes;
|
||||
while idx > 0 && !s.is_char_boundary(idx) {
|
||||
idx -= 1;
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
/// Notion channel — polls a Notion database for pending tasks and writes results back.
|
||||
///
|
||||
/// The channel connects to the Notion API, queries a database for rows with a "pending"
|
||||
/// status, dispatches them as channel messages, and writes results back when processing
|
||||
/// completes. It supports crash recovery by resetting stale "running" tasks on startup.
|
||||
pub struct NotionChannel {
|
||||
api_key: String,
|
||||
database_id: String,
|
||||
poll_interval_secs: u64,
|
||||
status_property: String,
|
||||
input_property: String,
|
||||
result_property: String,
|
||||
max_concurrent: usize,
|
||||
status_type: Arc<RwLock<String>>,
|
||||
inflight: Arc<RwLock<HashSet<String>>>,
|
||||
http: reqwest::Client,
|
||||
recover_stale: bool,
|
||||
}
|
||||
|
||||
impl NotionChannel {
|
||||
/// Create a new Notion channel with the given configuration.
|
||||
pub fn new(
|
||||
api_key: String,
|
||||
database_id: String,
|
||||
poll_interval_secs: u64,
|
||||
status_property: String,
|
||||
input_property: String,
|
||||
result_property: String,
|
||||
max_concurrent: usize,
|
||||
recover_stale: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
database_id,
|
||||
poll_interval_secs,
|
||||
status_property,
|
||||
input_property,
|
||||
result_property,
|
||||
max_concurrent,
|
||||
status_type: Arc::new(RwLock::new("select".to_string())),
|
||||
inflight: Arc::new(RwLock::new(HashSet::new())),
|
||||
http: reqwest::Client::new(),
|
||||
recover_stale,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the standard Notion API headers (Authorization, version, content-type).
|
||||
fn headers(&self) -> Result<reqwest::header::HeaderMap> {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
"Authorization",
|
||||
format!("Bearer {}", self.api_key)
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid Notion API key header value: {e}"))?,
|
||||
);
|
||||
headers.insert("Notion-Version", NOTION_VERSION.parse().unwrap());
|
||||
headers.insert("Content-Type", "application/json".parse().unwrap());
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
/// Make a Notion API call with automatic retry on rate-limit (429) and server errors (5xx).
|
||||
async fn api_call(
|
||||
&self,
|
||||
method: reqwest::Method,
|
||||
url: &str,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let mut last_err = None;
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
let mut req = self
|
||||
.http
|
||||
.request(method.clone(), url)
|
||||
.headers(self.headers()?);
|
||||
if let Some(ref b) = body {
|
||||
req = req.json(b);
|
||||
}
|
||||
match req.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
return resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to parse response: {e}"));
|
||||
}
|
||||
let status_code = status.as_u16();
|
||||
// Only retry on 429 (rate limit) or 5xx (server errors)
|
||||
if status_code != 429 && (400..500).contains(&status_code) {
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
let truncated =
|
||||
crate::util::truncate_with_ellipsis(&body_text, MAX_ERROR_BODY_CHARS);
|
||||
bail!("Notion API error {status_code}: {truncated}");
|
||||
}
|
||||
last_err = Some(anyhow::anyhow!("Notion API error: {status_code}"));
|
||||
}
|
||||
Err(e) => {
|
||||
last_err = Some(anyhow::anyhow!("HTTP request failed: {e}"));
|
||||
}
|
||||
}
|
||||
let delay = RETRY_BASE_DELAY_MS * 2u64.pow(attempt);
|
||||
tracing::warn!(
|
||||
"Notion API call failed (attempt {}/{}), retrying in {}ms",
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
delay
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("Notion API call failed after retries")))
|
||||
}
|
||||
|
||||
/// Query the database schema and detect whether Status uses "select" or "status" type.
|
||||
async fn detect_status_type(&self) -> Result<String> {
|
||||
let url = format!("{NOTION_API_BASE}/databases/{}", self.database_id);
|
||||
let resp = self.api_call(reqwest::Method::GET, &url, None).await?;
|
||||
let status_type = resp
|
||||
.get("properties")
|
||||
.and_then(|p| p.get(&self.status_property))
|
||||
.and_then(|s| s.get("type"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("select")
|
||||
.to_string();
|
||||
Ok(status_type)
|
||||
}
|
||||
|
||||
/// Query for rows where Status = "pending".
|
||||
async fn query_pending(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let url = format!("{NOTION_API_BASE}/databases/{}/query", self.database_id);
|
||||
let status_type = self.status_type.read().await.clone();
|
||||
let filter = build_status_filter(&self.status_property, &status_type, "pending");
|
||||
let resp = self
|
||||
.api_call(
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
Some(serde_json::json!({ "filter": filter })),
|
||||
)
|
||||
.await?;
|
||||
Ok(resp
|
||||
.get("results")
|
||||
.and_then(|r| r.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Atomically claim a task. Returns true if this caller got it.
|
||||
async fn claim_task(&self, page_id: &str) -> bool {
|
||||
let mut inflight = self.inflight.write().await;
|
||||
if inflight.contains(page_id) {
|
||||
return false;
|
||||
}
|
||||
if inflight.len() >= self.max_concurrent {
|
||||
return false;
|
||||
}
|
||||
inflight.insert(page_id.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Release a task from the inflight set.
|
||||
async fn release_task(&self, page_id: &str) {
|
||||
let mut inflight = self.inflight.write().await;
|
||||
inflight.remove(page_id);
|
||||
}
|
||||
|
||||
/// Update a row's status.
|
||||
async fn set_status(&self, page_id: &str, status_value: &str) -> Result<()> {
|
||||
let url = format!("{NOTION_API_BASE}/pages/{page_id}");
|
||||
let status_type = self.status_type.read().await.clone();
|
||||
let payload = serde_json::json!({
|
||||
"properties": {
|
||||
&self.status_property: build_status_payload(&status_type, status_value),
|
||||
}
|
||||
});
|
||||
self.api_call(reqwest::Method::PATCH, &url, Some(payload))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write result text to the Result column.
|
||||
async fn set_result(&self, page_id: &str, result_text: &str) -> Result<()> {
|
||||
let url = format!("{NOTION_API_BASE}/pages/{page_id}");
|
||||
let payload = serde_json::json!({
|
||||
"properties": {
|
||||
&self.result_property: build_rich_text_payload(result_text),
|
||||
}
|
||||
});
|
||||
self.api_call(reqwest::Method::PATCH, &url, Some(payload))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On startup, reset "running" tasks back to "pending" for crash recovery.
|
||||
async fn recover_stale(&self) -> Result<()> {
|
||||
let url = format!("{NOTION_API_BASE}/databases/{}/query", self.database_id);
|
||||
let status_type = self.status_type.read().await.clone();
|
||||
let filter = build_status_filter(&self.status_property, &status_type, "running");
|
||||
let resp = self
|
||||
.api_call(
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
Some(serde_json::json!({ "filter": filter })),
|
||||
)
|
||||
.await?;
|
||||
let stale = resp
|
||||
.get("results")
|
||||
.and_then(|r| r.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if stale.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
tracing::warn!(
|
||||
"Found {} stale task(s) in 'running' state, resetting to 'pending'",
|
||||
stale.len()
|
||||
);
|
||||
for task in &stale {
|
||||
if let Some(page_id) = task.get("id").and_then(|v| v.as_str()) {
|
||||
let page_url = format!("{NOTION_API_BASE}/pages/{page_id}");
|
||||
let payload = serde_json::json!({
|
||||
"properties": {
|
||||
&self.status_property: build_status_payload(&status_type, "pending"),
|
||||
&self.result_property: build_rich_text_payload(
|
||||
"Reset: poller restarted while task was running"
|
||||
),
|
||||
}
|
||||
});
|
||||
let short_id_end = floor_utf8_char_boundary(page_id, 8);
|
||||
let short_id = &page_id[..short_id_end];
|
||||
if let Err(e) = self
|
||||
.api_call(reqwest::Method::PATCH, &page_url, Some(payload))
|
||||
.await
|
||||
{
|
||||
tracing::error!("Could not reset stale task {short_id}: {e}");
|
||||
} else {
|
||||
tracing::info!("Reset stale task {short_id} to pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for NotionChannel {
|
||||
fn name(&self) -> &str {
|
||||
"notion"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||
// recipient is the page_id for Notion
|
||||
let page_id = &message.recipient;
|
||||
let status_type = self.status_type.read().await.clone();
|
||||
let url = format!("{NOTION_API_BASE}/pages/{page_id}");
|
||||
let payload = serde_json::json!({
|
||||
"properties": {
|
||||
&self.status_property: build_status_payload(&status_type, "done"),
|
||||
&self.result_property: build_rich_text_payload(&message.content),
|
||||
}
|
||||
});
|
||||
self.api_call(reqwest::Method::PATCH, &url, Some(payload))
|
||||
.await?;
|
||||
self.release_task(page_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
// Detect status property type
|
||||
match self.detect_status_type().await {
|
||||
Ok(st) => {
|
||||
tracing::info!("Notion status property type: {st}");
|
||||
*self.status_type.write().await = st;
|
||||
}
|
||||
Err(e) => {
|
||||
bail!("Failed to detect Notion database schema: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Crash recovery
|
||||
if self.recover_stale {
|
||||
if let Err(e) = self.recover_stale().await {
|
||||
tracing::error!("Notion stale task recovery failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Polling loop
|
||||
loop {
|
||||
match self.query_pending().await {
|
||||
Ok(tasks) => {
|
||||
if !tasks.is_empty() {
|
||||
tracing::info!("Notion: found {} pending task(s)", tasks.len());
|
||||
}
|
||||
for task in tasks {
|
||||
let page_id = match task.get("id").and_then(|v| v.as_str()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let input_text = extract_text_from_property(
|
||||
task.get("properties")
|
||||
.and_then(|p| p.get(&self.input_property)),
|
||||
);
|
||||
|
||||
if input_text.trim().is_empty() {
|
||||
let short_end = floor_utf8_char_boundary(&page_id, 8);
|
||||
tracing::warn!(
|
||||
"Notion: empty input for task {}, skipping",
|
||||
&page_id[..short_end]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.claim_task(&page_id).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set status to running
|
||||
if let Err(e) = self.set_status(&page_id, "running").await {
|
||||
tracing::error!("Notion: failed to set running status: {e}");
|
||||
self.release_task(&page_id).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
if tx
|
||||
.send(ChannelMessage {
|
||||
id: page_id.clone(),
|
||||
sender: "notion".into(),
|
||||
reply_target: page_id,
|
||||
content: input_text,
|
||||
channel: "notion".into(),
|
||||
timestamp,
|
||||
thread_ts: None,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::info!("Notion channel shutting down");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Notion poll error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(self.poll_interval_secs)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let url = format!("{NOTION_API_BASE}/databases/{}", self.database_id);
|
||||
self.api_call(reqwest::Method::GET, &url, None)
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper functions ──────────────────────────────────────────────
|
||||
|
||||
/// Build a Notion API filter object for the given status property.
|
||||
fn build_status_filter(property: &str, status_type: &str, value: &str) -> serde_json::Value {
|
||||
if status_type == "status" {
|
||||
serde_json::json!({
|
||||
"property": property,
|
||||
"status": { "equals": value }
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"property": property,
|
||||
"select": { "equals": value }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Notion API property-update payload for a status field.
|
||||
fn build_status_payload(status_type: &str, value: &str) -> serde_json::Value {
|
||||
if status_type == "status" {
|
||||
serde_json::json!({ "status": { "name": value } })
|
||||
} else {
|
||||
serde_json::json!({ "select": { "name": value } })
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Notion API rich-text property payload, truncating if necessary.
|
||||
fn build_rich_text_payload(value: &str) -> serde_json::Value {
|
||||
let truncated = truncate_result(value);
|
||||
serde_json::json!({
|
||||
"rich_text": [{
|
||||
"text": { "content": truncated }
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
/// Truncate result text to fit within the Notion rich-text content limit.
|
||||
fn truncate_result(value: &str) -> String {
|
||||
if value.len() <= MAX_RESULT_LENGTH {
|
||||
return value.to_string();
|
||||
}
|
||||
let cut = MAX_RESULT_LENGTH.saturating_sub(30);
|
||||
// Ensure we cut on a char boundary
|
||||
let end = floor_utf8_char_boundary(value, cut);
|
||||
format!("{}\n\n... [output truncated]", &value[..end])
|
||||
}
|
||||
|
||||
/// Extract plain text from a Notion property (title or rich_text type).
|
||||
fn extract_text_from_property(prop: Option<&serde_json::Value>) -> String {
|
||||
let Some(prop) = prop else {
|
||||
return String::new();
|
||||
};
|
||||
let ptype = prop.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let array_key = match ptype {
|
||||
"title" => "title",
|
||||
"rich_text" => "rich_text",
|
||||
_ => return String::new(),
|
||||
};
|
||||
prop.get(array_key)
|
||||
.and_then(|arr| arr.as_array())
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.get("plain_text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn claim_task_deduplication() {
|
||||
let channel = NotionChannel::new(
|
||||
"test-key".into(),
|
||||
"test-db".into(),
|
||||
5,
|
||||
"Status".into(),
|
||||
"Input".into(),
|
||||
"Result".into(),
|
||||
4,
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(channel.claim_task("page-1").await);
|
||||
// Second claim for same page should fail
|
||||
assert!(!channel.claim_task("page-1").await);
|
||||
// Different page should succeed
|
||||
assert!(channel.claim_task("page-2").await);
|
||||
|
||||
// After release, can claim again
|
||||
channel.release_task("page-1").await;
|
||||
assert!(channel.claim_task("page-1").await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_truncation_within_limit() {
|
||||
let short = "hello world";
|
||||
assert_eq!(truncate_result(short), short);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_truncation_over_limit() {
|
||||
let long = "a".repeat(MAX_RESULT_LENGTH + 100);
|
||||
let truncated = truncate_result(&long);
|
||||
assert!(truncated.len() <= MAX_RESULT_LENGTH);
|
||||
assert!(truncated.ends_with("... [output truncated]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_truncation_multibyte_safe() {
|
||||
// Build a string that would cut in the middle of a multibyte char
|
||||
let mut s = String::new();
|
||||
for _ in 0..700 {
|
||||
s.push('\u{6E2C}'); // 3-byte UTF-8 char
|
||||
}
|
||||
let truncated = truncate_result(&s);
|
||||
// Should not panic and should be valid UTF-8
|
||||
assert!(truncated.len() <= MAX_RESULT_LENGTH);
|
||||
assert!(truncated.ends_with("... [output truncated]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_payload_select_type() {
|
||||
let payload = build_status_payload("select", "pending");
|
||||
assert_eq!(
|
||||
payload,
|
||||
serde_json::json!({ "select": { "name": "pending" } })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_payload_status_type() {
|
||||
let payload = build_status_payload("status", "done");
|
||||
assert_eq!(payload, serde_json::json!({ "status": { "name": "done" } }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_text_payload_construction() {
|
||||
let payload = build_rich_text_payload("test output");
|
||||
let text = payload["rich_text"][0]["text"]["content"].as_str().unwrap();
|
||||
assert_eq!(text, "test output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_filter_select_type() {
|
||||
let filter = build_status_filter("Status", "select", "pending");
|
||||
assert_eq!(
|
||||
filter,
|
||||
serde_json::json!({
|
||||
"property": "Status",
|
||||
"select": { "equals": "pending" }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_filter_status_type() {
|
||||
let filter = build_status_filter("Status", "status", "running");
|
||||
assert_eq!(
|
||||
filter,
|
||||
serde_json::json!({
|
||||
"property": "Status",
|
||||
"status": { "equals": "running" }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_text_from_title_property() {
|
||||
let prop = serde_json::json!({
|
||||
"type": "title",
|
||||
"title": [
|
||||
{ "plain_text": "Hello " },
|
||||
{ "plain_text": "World" }
|
||||
]
|
||||
});
|
||||
assert_eq!(extract_text_from_property(Some(&prop)), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_text_from_rich_text_property() {
|
||||
let prop = serde_json::json!({
|
||||
"type": "rich_text",
|
||||
"rich_text": [{ "plain_text": "task content" }]
|
||||
});
|
||||
assert_eq!(extract_text_from_property(Some(&prop)), "task content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_text_from_none() {
|
||||
assert_eq!(extract_text_from_property(None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_text_from_unknown_type() {
|
||||
let prop = serde_json::json!({ "type": "number", "number": 42 });
|
||||
assert_eq!(extract_text_from_property(Some(&prop)), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn claim_task_respects_max_concurrent() {
|
||||
let channel = NotionChannel::new(
|
||||
"test-key".into(),
|
||||
"test-db".into(),
|
||||
5,
|
||||
"Status".into(),
|
||||
"Input".into(),
|
||||
"Result".into(),
|
||||
2, // max_concurrent = 2
|
||||
false,
|
||||
);
|
||||
|
||||
assert!(channel.claim_task("page-1").await);
|
||||
assert!(channel.claim_task("page-2").await);
|
||||
// Third claim should be rejected (at capacity)
|
||||
assert!(!channel.claim_task("page-3").await);
|
||||
|
||||
// After releasing one, can claim again
|
||||
channel.release_task("page-1").await;
|
||||
assert!(channel.claim_task("page-3").await);
|
||||
}
|
||||
}
|
||||
+39
-4
@@ -257,8 +257,10 @@ impl Channel for QQChannel {
|
||||
(
|
||||
format!("{QQ_API_BASE}/v2/groups/{group_id}/messages"),
|
||||
json!({
|
||||
"content": &message.content,
|
||||
"msg_type": 0,
|
||||
"markdown": {
|
||||
"content": &message.content,
|
||||
},
|
||||
"msg_type": 2,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
@@ -273,8 +275,10 @@ impl Channel for QQChannel {
|
||||
(
|
||||
format!("{QQ_API_BASE}/v2/users/{user_id}/messages"),
|
||||
json!({
|
||||
"content": &message.content,
|
||||
"msg_type": 0,
|
||||
"markdown": {
|
||||
"content": &message.content,
|
||||
},
|
||||
"msg_type": 2,
|
||||
}),
|
||||
)
|
||||
};
|
||||
@@ -667,4 +671,35 @@ allowed_users = ["user1"]
|
||||
|
||||
assert_eq!(compose_message_content(&payload), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_send_body_uses_markdown_msg_type() {
|
||||
// Verify the expected JSON shape for both group and user send paths.
|
||||
// msg_type 2 with a nested markdown object is required by the QQ API
|
||||
// for markdown rendering; msg_type 0 (plain text) causes markdown
|
||||
// syntax to appear literally in the client.
|
||||
let content = "**bold** and `code`";
|
||||
|
||||
let group_body = json!({
|
||||
"markdown": { "content": content },
|
||||
"msg_type": 2,
|
||||
});
|
||||
assert_eq!(group_body["msg_type"], 2);
|
||||
assert_eq!(group_body["markdown"]["content"], content);
|
||||
assert!(
|
||||
group_body.get("content").is_none(),
|
||||
"top-level 'content' must not be present"
|
||||
);
|
||||
|
||||
let user_body = json!({
|
||||
"markdown": { "content": content },
|
||||
"msg_type": 2,
|
||||
});
|
||||
assert_eq!(user_body["msg_type"], 2);
|
||||
assert_eq!(user_body["markdown"]["content"], content);
|
||||
assert!(
|
||||
user_body.get("content").is_none(),
|
||||
"top-level 'content' must not be present"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use anyhow::{bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Deserialize;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Reddit channel — polls for mentions, DMs, and comment replies via Reddit OAuth2 API.
|
||||
pub struct RedditChannel {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
refresh_token: String,
|
||||
username: String,
|
||||
subreddit: Option<String>,
|
||||
auth: Mutex<RedditAuth>,
|
||||
}
|
||||
|
||||
struct RedditAuth {
|
||||
access_token: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RedditTokenResponse {
|
||||
access_token: String,
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RedditListing {
|
||||
data: RedditListingData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RedditListingData {
|
||||
children: Vec<RedditChild>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RedditChild {
|
||||
data: RedditItemData,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Deserialize)]
|
||||
struct RedditItemData {
|
||||
name: Option<String>,
|
||||
author: Option<String>,
|
||||
body: Option<String>,
|
||||
subject: Option<String>,
|
||||
parent_id: Option<String>,
|
||||
link_id: Option<String>,
|
||||
subreddit: Option<String>,
|
||||
created_utc: Option<f64>,
|
||||
new: Option<bool>,
|
||||
#[serde(rename = "type")]
|
||||
message_type: Option<String>,
|
||||
context: Option<String>,
|
||||
}
|
||||
|
||||
const REDDIT_API_BASE: &str = "https://oauth.reddit.com";
|
||||
const REDDIT_TOKEN_URL: &str = "https://www.reddit.com/api/v1/access_token";
|
||||
const USER_AGENT: &str = "zeroclaw:channel:v0.1.0 (by /u/zeroclaw-bot)";
|
||||
/// Reddit enforces 60 requests per minute.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
impl RedditChannel {
|
||||
pub fn new(
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
refresh_token: String,
|
||||
username: String,
|
||||
subreddit: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client_id,
|
||||
client_secret,
|
||||
refresh_token,
|
||||
username,
|
||||
subreddit,
|
||||
auth: Mutex::new(RedditAuth {
|
||||
access_token: String::new(),
|
||||
expires_at: Instant::now(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.reddit")
|
||||
}
|
||||
|
||||
/// Refresh the OAuth2 access token using the refresh token.
|
||||
async fn refresh_access_token(&self) -> Result<()> {
|
||||
let client = self.http_client();
|
||||
let resp = client
|
||||
.post(REDDIT_TOKEN_URL)
|
||||
.basic_auth(&self.client_id, Some(&self.client_secret))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", &self.refresh_token),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
bail!("Reddit token refresh failed ({status}): {body}");
|
||||
}
|
||||
|
||||
let token_resp: RedditTokenResponse = resp.json().await?;
|
||||
let mut auth = self.auth.lock();
|
||||
auth.access_token = token_resp.access_token;
|
||||
auth.expires_at =
|
||||
Instant::now() + Duration::from_secs(token_resp.expires_in.saturating_sub(60));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a valid access token, refreshing if expired.
|
||||
async fn get_access_token(&self) -> Result<String> {
|
||||
{
|
||||
let auth = self.auth.lock();
|
||||
if !auth.access_token.is_empty() && Instant::now() < auth.expires_at {
|
||||
return Ok(auth.access_token.clone());
|
||||
}
|
||||
}
|
||||
self.refresh_access_token().await?;
|
||||
let auth = self.auth.lock();
|
||||
Ok(auth.access_token.clone())
|
||||
}
|
||||
|
||||
/// Fetch unread inbox items (mentions, DMs, comment replies).
|
||||
async fn fetch_inbox(&self) -> Result<Vec<RedditChild>> {
|
||||
let token = self.get_access_token().await?;
|
||||
let client = self.http_client();
|
||||
|
||||
let resp = client
|
||||
.get(format!("{REDDIT_API_BASE}/message/unread"))
|
||||
.bearer_auth(&token)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.query(&[("limit", "25")])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
tracing::warn!("Reddit inbox fetch failed ({status}): {body}");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let listing: RedditListing = resp.json().await?;
|
||||
Ok(listing.data.children)
|
||||
}
|
||||
|
||||
/// Mark inbox items as read.
|
||||
async fn mark_read(&self, fullnames: &[String]) -> Result<()> {
|
||||
if fullnames.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let token = self.get_access_token().await?;
|
||||
let client = self.http_client();
|
||||
|
||||
let ids = fullnames.join(",");
|
||||
let resp = client
|
||||
.post(format!("{REDDIT_API_BASE}/api/read_message"))
|
||||
.bearer_auth(&token)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.form(&[("id", ids.as_str())])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!("Reddit mark_read failed: {}", resp.status());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a Reddit inbox item into a ChannelMessage.
|
||||
fn parse_item(&self, item: &RedditItemData) -> Option<ChannelMessage> {
|
||||
let author = item.author.as_deref().unwrap_or("");
|
||||
let body = item.body.as_deref().unwrap_or("");
|
||||
let name = item.name.as_deref().unwrap_or("");
|
||||
|
||||
// Skip messages from ourselves
|
||||
if author.eq_ignore_ascii_case(&self.username) || author.is_empty() || body.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If a subreddit filter is set, skip items from other subreddits
|
||||
if let Some(ref sub) = self.subreddit {
|
||||
if let Some(ref item_sub) = item.subreddit {
|
||||
if !item_sub.eq_ignore_ascii_case(sub) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine reply target: for comment replies use the parent thing name,
|
||||
// for DMs reply to the author.
|
||||
let reply_target =
|
||||
if item.message_type.as_deref() == Some("comment_reply") || item.parent_id.is_some() {
|
||||
// For comment replies, the recipient is the parent fullname
|
||||
item.parent_id.clone().unwrap_or_else(|| name.to_string())
|
||||
} else {
|
||||
// For DMs, reply to the author
|
||||
author.to_string()
|
||||
};
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let timestamp = item.created_utc.unwrap_or(0.0) as u64;
|
||||
|
||||
Some(ChannelMessage {
|
||||
id: format!("reddit_{name}"),
|
||||
sender: author.to_string(),
|
||||
reply_target,
|
||||
content: body.to_string(),
|
||||
channel: "reddit".to_string(),
|
||||
timestamp,
|
||||
thread_ts: item.parent_id.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for RedditChannel {
|
||||
fn name(&self) -> &str {
|
||||
"reddit"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||
let token = self.get_access_token().await?;
|
||||
let client = self.http_client();
|
||||
|
||||
// If recipient looks like a Reddit fullname (t1_, t3_, t4_), it's a comment reply.
|
||||
// Otherwise treat it as a DM to a username.
|
||||
if message.recipient.starts_with("t1_")
|
||||
|| message.recipient.starts_with("t3_")
|
||||
|| message.recipient.starts_with("t4_")
|
||||
{
|
||||
// Comment reply
|
||||
let resp = client
|
||||
.post(format!("{REDDIT_API_BASE}/api/comment"))
|
||||
.bearer_auth(&token)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.form(&[
|
||||
("thing_id", message.recipient.as_str()),
|
||||
("text", &message.content),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
bail!("Reddit comment reply failed ({status}): {body}");
|
||||
}
|
||||
} else {
|
||||
// Direct message
|
||||
let subject = message
|
||||
.subject
|
||||
.as_deref()
|
||||
.unwrap_or("Message from ZeroClaw");
|
||||
let resp = client
|
||||
.post(format!("{REDDIT_API_BASE}/api/compose"))
|
||||
.bearer_auth(&token)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.form(&[
|
||||
("to", message.recipient.as_str()),
|
||||
("subject", subject),
|
||||
("text", &message.content),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
bail!("Reddit DM failed ({status}): {body}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
// Initial auth
|
||||
self.refresh_access_token().await?;
|
||||
|
||||
tracing::info!(
|
||||
"Reddit channel listening as u/{} {}...",
|
||||
self.username,
|
||||
self.subreddit
|
||||
.as_ref()
|
||||
.map(|s| format!("in r/{s}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
||||
let items = match self.fetch_inbox().await {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
tracing::warn!("Reddit poll error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut read_ids = Vec::new();
|
||||
for child in &items {
|
||||
if let Some(ref name) = child.data.name {
|
||||
read_ids.push(name.clone());
|
||||
}
|
||||
if let Some(msg) = self.parse_item(&child.data) {
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.mark_read(&read_ids).await {
|
||||
tracing::warn!("Reddit mark_read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.get_access_token().await.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_channel() -> RedditChannel {
|
||||
RedditChannel::new(
|
||||
"client_id".into(),
|
||||
"client_secret".into(),
|
||||
"refresh_token".into(),
|
||||
"testbot".into(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_channel_with_sub(sub: &str) -> RedditChannel {
|
||||
RedditChannel::new(
|
||||
"client_id".into(),
|
||||
"client_secret".into(),
|
||||
"refresh_token".into(),
|
||||
"testbot".into(),
|
||||
Some(sub.into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_comment_reply() {
|
||||
let ch = make_channel();
|
||||
let item = RedditItemData {
|
||||
name: Some("t1_abc123".into()),
|
||||
author: Some("user1".into()),
|
||||
body: Some("hello bot".into()),
|
||||
subject: None,
|
||||
parent_id: Some("t1_parent1".into()),
|
||||
link_id: Some("t3_post1".into()),
|
||||
subreddit: Some("rust".into()),
|
||||
created_utc: Some(1_700_000_000.0),
|
||||
new: Some(true),
|
||||
message_type: Some("comment_reply".into()),
|
||||
context: None,
|
||||
};
|
||||
|
||||
let msg = ch.parse_item(&item).unwrap();
|
||||
assert_eq!(msg.sender, "user1");
|
||||
assert_eq!(msg.content, "hello bot");
|
||||
assert_eq!(msg.reply_target, "t1_parent1");
|
||||
assert_eq!(msg.channel, "reddit");
|
||||
assert_eq!(msg.id, "reddit_t1_abc123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dm() {
|
||||
let ch = make_channel();
|
||||
let item = RedditItemData {
|
||||
name: Some("t4_dm456".into()),
|
||||
author: Some("user2".into()),
|
||||
body: Some("private message".into()),
|
||||
subject: Some("Hello".into()),
|
||||
parent_id: None,
|
||||
link_id: None,
|
||||
subreddit: None,
|
||||
created_utc: Some(1_700_000_100.0),
|
||||
new: Some(true),
|
||||
message_type: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
let msg = ch.parse_item(&item).unwrap();
|
||||
assert_eq!(msg.sender, "user2");
|
||||
assert_eq!(msg.content, "private message");
|
||||
assert_eq!(msg.reply_target, "user2"); // DM reply goes to author
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_self_messages() {
|
||||
let ch = make_channel();
|
||||
let item = RedditItemData {
|
||||
name: Some("t1_self".into()),
|
||||
author: Some("testbot".into()),
|
||||
body: Some("my own message".into()),
|
||||
subject: None,
|
||||
parent_id: None,
|
||||
link_id: None,
|
||||
subreddit: None,
|
||||
created_utc: Some(1_700_000_000.0),
|
||||
new: Some(true),
|
||||
message_type: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
assert!(ch.parse_item(&item).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skip_empty_body() {
|
||||
let ch = make_channel();
|
||||
let item = RedditItemData {
|
||||
name: Some("t1_empty".into()),
|
||||
author: Some("user1".into()),
|
||||
body: Some(String::new()),
|
||||
subject: None,
|
||||
parent_id: None,
|
||||
link_id: None,
|
||||
subreddit: None,
|
||||
created_utc: Some(1_700_000_000.0),
|
||||
new: Some(true),
|
||||
message_type: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
assert!(ch.parse_item(&item).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subreddit_filter() {
|
||||
let ch = make_channel_with_sub("rust");
|
||||
let item = RedditItemData {
|
||||
name: Some("t1_other".into()),
|
||||
author: Some("user1".into()),
|
||||
body: Some("hello".into()),
|
||||
subject: None,
|
||||
parent_id: None,
|
||||
link_id: None,
|
||||
subreddit: Some("python".into()),
|
||||
created_utc: Some(1_700_000_000.0),
|
||||
new: Some(true),
|
||||
message_type: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
assert!(ch.parse_item(&item).is_none());
|
||||
|
||||
let matching_item = RedditItemData {
|
||||
name: Some("t1_match".into()),
|
||||
author: Some("user1".into()),
|
||||
body: Some("hello".into()),
|
||||
subject: None,
|
||||
parent_id: None,
|
||||
link_id: None,
|
||||
subreddit: Some("rust".into()),
|
||||
created_utc: Some(1_700_000_000.0),
|
||||
new: Some(true),
|
||||
message_type: None,
|
||||
context: None,
|
||||
};
|
||||
|
||||
assert!(ch.parse_item(&matching_item).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_message_formatting() {
|
||||
// Verify SendMessage can be constructed for both DM and comment reply
|
||||
let dm = SendMessage::new("hello", "user1");
|
||||
assert_eq!(dm.recipient, "user1");
|
||||
assert_eq!(dm.content, "hello");
|
||||
|
||||
let reply = SendMessage::new("response", "t1_abc123");
|
||||
assert!(reply.recipient.starts_with("t1_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Trait abstraction for session persistence backends.
|
||||
//!
|
||||
//! Backends store per-sender conversation histories. The trait is intentionally
|
||||
//! minimal — load, append, remove_last, list — so that JSONL and SQLite (and
|
||||
//! future backends) share a common interface.
|
||||
|
||||
use crate::providers::traits::ChatMessage;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Metadata about a persisted session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionMetadata {
|
||||
/// Session key (e.g. `telegram_user123`).
|
||||
pub key: String,
|
||||
/// When the session was first created.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// When the last message was appended.
|
||||
pub last_activity: DateTime<Utc>,
|
||||
/// Total number of messages in the session.
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
/// Query parameters for listing sessions.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SessionQuery {
|
||||
/// Keyword to search in session messages (FTS5 if available).
|
||||
pub keyword: Option<String>,
|
||||
/// Maximum number of sessions to return.
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Trait for session persistence backends.
|
||||
///
|
||||
/// Implementations must be `Send + Sync` for sharing across async tasks.
|
||||
pub trait SessionBackend: Send + Sync {
|
||||
/// Load all messages for a session. Returns empty vec if session doesn't exist.
|
||||
fn load(&self, session_key: &str) -> Vec<ChatMessage>;
|
||||
|
||||
/// Append a single message to a session.
|
||||
fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()>;
|
||||
|
||||
/// Remove the last message from a session. Returns `true` if a message was removed.
|
||||
fn remove_last(&self, session_key: &str) -> std::io::Result<bool>;
|
||||
|
||||
/// List all session keys.
|
||||
fn list_sessions(&self) -> Vec<String>;
|
||||
|
||||
/// List sessions with metadata.
|
||||
fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
|
||||
// Default: construct metadata from messages (backends can override for efficiency)
|
||||
self.list_sessions()
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let messages = self.load(&key);
|
||||
SessionMetadata {
|
||||
key,
|
||||
created_at: Utc::now(),
|
||||
last_activity: Utc::now(),
|
||||
message_count: messages.len(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compact a session file (remove duplicates/corruption). No-op by default.
|
||||
fn compact(&self, _session_key: &str) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove sessions that haven't been active within the given TTL hours.
|
||||
fn cleanup_stale(&self, _ttl_hours: u32) -> std::io::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Search sessions by keyword. Default returns empty (backends with FTS override).
|
||||
fn search(&self, _query: &SessionQuery) -> Vec<SessionMetadata> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Delete all messages for a session. Returns `true` if the session existed.
|
||||
fn delete_session(&self, _session_key: &str) -> std::io::Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_metadata_is_constructible() {
|
||||
let meta = SessionMetadata {
|
||||
key: "test".into(),
|
||||
created_at: Utc::now(),
|
||||
last_activity: Utc::now(),
|
||||
message_count: 5,
|
||||
};
|
||||
assert_eq!(meta.key, "test");
|
||||
assert_eq!(meta.message_count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_query_defaults() {
|
||||
let q = SessionQuery::default();
|
||||
assert!(q.keyword.is_none());
|
||||
assert!(q.limit.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! SQLite-backed session persistence with FTS5 search.
|
||||
//!
|
||||
//! Stores sessions in `{workspace}/sessions/sessions.db` using WAL mode.
|
||||
//! Provides full-text search via FTS5 and automatic TTL-based cleanup.
|
||||
//! Designed as the default backend, replacing JSONL for new installations.
|
||||
|
||||
use crate::channels::session_backend::{SessionBackend, SessionMetadata, SessionQuery};
|
||||
use crate::providers::traits::ChatMessage;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// SQLite-backed session store with FTS5 and WAL mode.
|
||||
pub struct SqliteSessionBackend {
|
||||
conn: Mutex<Connection>,
|
||||
#[allow(dead_code)]
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SqliteSessionBackend {
|
||||
/// Open or create the sessions database.
|
||||
pub fn new(workspace_dir: &Path) -> Result<Self> {
|
||||
let sessions_dir = workspace_dir.join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).context("Failed to create sessions directory")?;
|
||||
let db_path = sessions_dir.join("sessions.db");
|
||||
|
||||
let conn = Connection::open(&db_path)
|
||||
.with_context(|| format!("Failed to open session DB: {}", db_path.display()))?;
|
||||
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA mmap_size = 4194304;",
|
||||
)?;
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_key TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_key ON sessions(session_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_key_id ON sessions(session_key, id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_metadata (
|
||||
session_key TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
last_activity TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
|
||||
session_key, content, content=sessions, content_rowid=id
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
|
||||
INSERT INTO sessions_fts(rowid, session_key, content)
|
||||
VALUES (new.id, new.session_key, new.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
|
||||
INSERT INTO sessions_fts(sessions_fts, rowid, session_key, content)
|
||||
VALUES ('delete', old.id, old.session_key, old.content);
|
||||
END;",
|
||||
)
|
||||
.context("Failed to initialize session schema")?;
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
db_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Migrate JSONL session files into SQLite. Renames migrated files to `.jsonl.migrated`.
|
||||
pub fn migrate_from_jsonl(&self, workspace_dir: &Path) -> Result<usize> {
|
||||
let sessions_dir = workspace_dir.join("sessions");
|
||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Ok(0),
|
||||
};
|
||||
|
||||
let mut migrated = 0;
|
||||
for entry in entries {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let name = match entry.file_name().into_string() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let Some(key) = name.strip_suffix(".jsonl") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
let file = match std::fs::File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut count = 0;
|
||||
for line in std::io::BufRead::lines(reader) {
|
||||
let Ok(line) = line else { continue };
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(msg) = serde_json::from_str::<ChatMessage>(trimmed) {
|
||||
if self.append(key, &msg).is_ok() {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
let migrated_path = path.with_extension("jsonl.migrated");
|
||||
let _ = std::fs::rename(&path, &migrated_path);
|
||||
migrated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(migrated)
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionBackend for SqliteSessionBackend {
|
||||
fn load(&self, session_key: &str) -> Vec<ChatMessage> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT role, content FROM sessions WHERE session_key = ?1 ORDER BY id ASC")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let rows = match stmt.query_map(params![session_key], |row| {
|
||||
Ok(ChatMessage {
|
||||
role: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
})
|
||||
}) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_key, role, content, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![session_key, message.role, message.content, now],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// Upsert metadata
|
||||
conn.execute(
|
||||
"INSERT INTO session_metadata (session_key, created_at, last_activity, message_count)
|
||||
VALUES (?1, ?2, ?3, 1)
|
||||
ON CONFLICT(session_key) DO UPDATE SET
|
||||
last_activity = excluded.last_activity,
|
||||
message_count = message_count + 1",
|
||||
params![session_key, now, now],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_last(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
let last_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM sessions WHERE session_key = ?1 ORDER BY id DESC LIMIT 1",
|
||||
params![session_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
|
||||
let Some(id) = last_id else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// Update metadata count
|
||||
conn.execute(
|
||||
"UPDATE session_metadata SET message_count = MAX(0, message_count - 1)
|
||||
WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn list_sessions(&self) -> Vec<String> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT session_key FROM session_metadata ORDER BY last_activity DESC")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
fn list_sessions_with_metadata(&self) -> Vec<SessionMetadata> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT session_key, created_at, last_activity, message_count
|
||||
FROM session_metadata ORDER BY last_activity DESC",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let rows = match stmt.query_map([], |row| {
|
||||
let key: String = row.get(0)?;
|
||||
let created_str: String = row.get(1)?;
|
||||
let activity_str: String = row.get(2)?;
|
||||
let count: i64 = row.get(3)?;
|
||||
|
||||
let created = DateTime::parse_from_rfc3339(&created_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
let activity = DateTime::parse_from_rfc3339(&activity_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
Ok(SessionMetadata {
|
||||
key,
|
||||
created_at: created,
|
||||
last_activity: activity,
|
||||
message_count: count as usize,
|
||||
})
|
||||
}) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
}
|
||||
|
||||
fn cleanup_stale(&self, ttl_hours: u32) -> std::io::Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let cutoff = (Utc::now() - Duration::hours(i64::from(ttl_hours))).to_rfc3339();
|
||||
|
||||
// Find stale sessions
|
||||
let stale_keys: Vec<String> = {
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT session_key FROM session_metadata WHERE last_activity < ?1")
|
||||
.map_err(std::io::Error::other)?;
|
||||
let rows = stmt
|
||||
.query_map(params![cutoff], |row| row.get(0))
|
||||
.map_err(std::io::Error::other)?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let count = stale_keys.len();
|
||||
for key in &stale_keys {
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE session_key = ?1", params![key]);
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM session_metadata WHERE session_key = ?1",
|
||||
params![key],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn delete_session(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
|
||||
// Check if session exists
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM session_metadata WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Delete messages (FTS5 trigger handles sessions_fts cleanup)
|
||||
conn.execute(
|
||||
"DELETE FROM sessions WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// Delete metadata
|
||||
conn.execute(
|
||||
"DELETE FROM session_metadata WHERE session_key = ?1",
|
||||
params![session_key],
|
||||
)
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn search(&self, query: &SessionQuery) -> Vec<SessionMetadata> {
|
||||
let Some(keyword) = &query.keyword else {
|
||||
return self.list_sessions_with_metadata();
|
||||
};
|
||||
|
||||
let conn = self.conn.lock();
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
let limit = query.limit.unwrap_or(50) as i64;
|
||||
|
||||
// FTS5 search
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT DISTINCT f.session_key
|
||||
FROM sessions_fts f
|
||||
WHERE sessions_fts MATCH ?1
|
||||
LIMIT ?2",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Quote each word for FTS5
|
||||
let fts_query: String = keyword
|
||||
.split_whitespace()
|
||||
.map(|w| format!("\"{w}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
|
||||
let keys: Vec<String> = match stmt.query_map(params![fts_query, limit], |row| row.get(0)) {
|
||||
Ok(r) => r.filter_map(|r| r.ok()).collect(),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
// Look up metadata for matched sessions
|
||||
keys.iter()
|
||||
.filter_map(|key| {
|
||||
conn.query_row(
|
||||
"SELECT created_at, last_activity, message_count FROM session_metadata WHERE session_key = ?1",
|
||||
params![key],
|
||||
|row| {
|
||||
let created_str: String = row.get(0)?;
|
||||
let activity_str: String = row.get(1)?;
|
||||
let count: i64 = row.get(2)?;
|
||||
Ok(SessionMetadata {
|
||||
key: key.clone(),
|
||||
created_at: DateTime::parse_from_rfc3339(&created_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
last_activity: DateTime::parse_from_rfc3339(&activity_str)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now()),
|
||||
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
|
||||
message_count: count as usize,
|
||||
})
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn round_trip_sqlite() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend
|
||||
.append("user1", &ChatMessage::user("hello"))
|
||||
.unwrap();
|
||||
backend
|
||||
.append("user1", &ChatMessage::assistant("hi"))
|
||||
.unwrap();
|
||||
|
||||
let msgs = backend.load("user1");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].role, "user");
|
||||
assert_eq!(msgs[1].role, "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_sqlite() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend.append("u", &ChatMessage::user("a")).unwrap();
|
||||
backend.append("u", &ChatMessage::user("b")).unwrap();
|
||||
|
||||
assert!(backend.remove_last("u").unwrap());
|
||||
let msgs = backend.load("u");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].content, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_empty_sqlite() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
assert!(!backend.remove_last("nonexistent").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sessions_sqlite() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend.append("a", &ChatMessage::user("hi")).unwrap();
|
||||
backend.append("b", &ChatMessage::user("hey")).unwrap();
|
||||
|
||||
let sessions = backend.list_sessions();
|
||||
assert_eq!(sessions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_tracks_counts() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend.append("s1", &ChatMessage::user("a")).unwrap();
|
||||
backend.append("s1", &ChatMessage::user("b")).unwrap();
|
||||
backend.append("s1", &ChatMessage::user("c")).unwrap();
|
||||
|
||||
let meta = backend.list_sessions_with_metadata();
|
||||
assert_eq!(meta.len(), 1);
|
||||
assert_eq!(meta[0].message_count, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fts5_search_finds_content() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend
|
||||
.append(
|
||||
"code_chat",
|
||||
&ChatMessage::user("How do I parse JSON in Rust?"),
|
||||
)
|
||||
.unwrap();
|
||||
backend
|
||||
.append("weather", &ChatMessage::user("What's the weather today?"))
|
||||
.unwrap();
|
||||
|
||||
let results = backend.search(&SessionQuery {
|
||||
keyword: Some("Rust".into()),
|
||||
limit: Some(10),
|
||||
});
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].key, "code_chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_stale_removes_old_sessions() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
// Insert a session with old timestamp
|
||||
{
|
||||
let conn = backend.conn.lock();
|
||||
let old_time = (Utc::now() - Duration::hours(100)).to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (session_key, role, content, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params!["old_session", "user", "ancient", old_time],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO session_metadata (session_key, created_at, last_activity, message_count) VALUES (?1, ?2, ?3, 1)",
|
||||
params!["old_session", old_time, old_time],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
backend
|
||||
.append("new_session", &ChatMessage::user("fresh"))
|
||||
.unwrap();
|
||||
|
||||
let cleaned = backend.cleanup_stale(48).unwrap(); // 48h TTL
|
||||
assert_eq!(cleaned, 1);
|
||||
|
||||
let sessions = backend.list_sessions();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0], "new_session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_removes_all_data() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
|
||||
backend.append("s1", &ChatMessage::user("hello")).unwrap();
|
||||
backend.append("s1", &ChatMessage::assistant("hi")).unwrap();
|
||||
backend.append("s2", &ChatMessage::user("other")).unwrap();
|
||||
|
||||
assert!(backend.delete_session("s1").unwrap());
|
||||
assert!(backend.load("s1").is_empty());
|
||||
assert_eq!(backend.list_sessions().len(), 1);
|
||||
assert_eq!(backend.list_sessions()[0], "s2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_session_returns_false_for_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
assert!(!backend.delete_session("nonexistent").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_from_jsonl_imports_and_renames() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let sessions_dir = tmp.path().join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir).unwrap();
|
||||
|
||||
// Create a JSONL file
|
||||
let jsonl_path = sessions_dir.join("test_user.jsonl");
|
||||
std::fs::write(
|
||||
&jsonl_path,
|
||||
"{\"role\":\"user\",\"content\":\"hello\"}\n{\"role\":\"assistant\",\"content\":\"hi\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let backend = SqliteSessionBackend::new(tmp.path()).unwrap();
|
||||
let migrated = backend.migrate_from_jsonl(tmp.path()).unwrap();
|
||||
assert_eq!(migrated, 1);
|
||||
|
||||
// JSONL should be renamed
|
||||
assert!(!jsonl_path.exists());
|
||||
assert!(sessions_dir.join("test_user.jsonl.migrated").exists());
|
||||
|
||||
// Messages should be in SQLite
|
||||
let msgs = backend.load("test_user");
|
||||
assert_eq!(msgs.len(), 2);
|
||||
assert_eq!(msgs[0].content, "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! JSONL-based session persistence for channel conversations.
|
||||
//!
|
||||
//! Each session (keyed by `channel_sender` or `channel_thread_sender`) is stored
|
||||
//! as an append-only JSONL file in `{workspace}/sessions/`. Messages are appended
|
||||
//! one-per-line as JSON, never modifying old lines. On daemon restart, sessions
|
||||
//! are loaded from disk to restore conversation context.
|
||||
|
||||
use crate::channels::session_backend::SessionBackend;
|
||||
use crate::providers::traits::ChatMessage;
|
||||
use std::io::{BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Append-only JSONL session store for channel conversations.
|
||||
pub struct SessionStore {
|
||||
sessions_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Create a new session store, ensuring the sessions directory exists.
|
||||
pub fn new(workspace_dir: &Path) -> std::io::Result<Self> {
|
||||
let sessions_dir = workspace_dir.join("sessions");
|
||||
std::fs::create_dir_all(&sessions_dir)?;
|
||||
Ok(Self { sessions_dir })
|
||||
}
|
||||
|
||||
/// Compute the file path for a session key, sanitizing for filesystem safety.
|
||||
fn session_path(&self, session_key: &str) -> PathBuf {
|
||||
let safe_key: String = session_key
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_alphanumeric() || c == '_' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.sessions_dir.join(format!("{safe_key}.jsonl"))
|
||||
}
|
||||
|
||||
/// Load all messages for a session from its JSONL file.
|
||||
/// Returns an empty vec if the file does not exist or is unreadable.
|
||||
pub fn load(&self, session_key: &str) -> Vec<ChatMessage> {
|
||||
let path = self.session_path(session_key);
|
||||
let file = match std::fs::File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { continue };
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(msg) = serde_json::from_str::<ChatMessage>(trimmed) {
|
||||
messages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
/// Append a single message to the session JSONL file.
|
||||
pub fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()> {
|
||||
let path = self.session_path(session_key);
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)?;
|
||||
|
||||
let json = serde_json::to_string(message)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
writeln!(file, "{json}")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the last message from a session's JSONL file.
|
||||
///
|
||||
/// Rewrite approach: load all messages, drop the last, rewrite. This is
|
||||
/// O(n) but rollbacks are rare.
|
||||
pub fn remove_last(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
let mut messages = self.load(session_key);
|
||||
if messages.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
messages.pop();
|
||||
self.rewrite(session_key, &messages)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Compact a session file by rewriting only valid messages (removes corrupt lines).
|
||||
pub fn compact(&self, session_key: &str) -> std::io::Result<()> {
|
||||
let messages = self.load(session_key);
|
||||
self.rewrite(session_key, &messages)
|
||||
}
|
||||
|
||||
fn rewrite(&self, session_key: &str, messages: &[ChatMessage]) -> std::io::Result<()> {
|
||||
let path = self.session_path(session_key);
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
for msg in messages {
|
||||
let json = serde_json::to_string(msg)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
writeln!(file, "{json}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all session keys that have files on disk.
|
||||
pub fn list_sessions(&self) -> Vec<String> {
|
||||
let entries = match std::fs::read_dir(&self.sessions_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
entries
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let name = entry.file_name().into_string().ok()?;
|
||||
name.strip_suffix(".jsonl").map(String::from)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionBackend for SessionStore {
|
||||
fn load(&self, session_key: &str) -> Vec<ChatMessage> {
|
||||
self.load(session_key)
|
||||
}
|
||||
|
||||
fn append(&self, session_key: &str, message: &ChatMessage) -> std::io::Result<()> {
|
||||
self.append(session_key, message)
|
||||
}
|
||||
|
||||
fn remove_last(&self, session_key: &str) -> std::io::Result<bool> {
|
||||
self.remove_last(session_key)
|
||||
}
|
||||
|
||||
fn list_sessions(&self) -> Vec<String> {
|
||||
self.list_sessions()
|
||||
}
|
||||
|
||||
fn compact(&self, session_key: &str) -> std::io::Result<()> {
|
||||
self.compact(session_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn round_trip_append_and_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
|
||||
store
|
||||
.append("telegram_user123", &ChatMessage::user("hello"))
|
||||
.unwrap();
|
||||
store
|
||||
.append("telegram_user123", &ChatMessage::assistant("hi there"))
|
||||
.unwrap();
|
||||
|
||||
let messages = store.load("telegram_user123");
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].role, "assistant");
|
||||
assert_eq!(messages[1].content, "hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_nonexistent_session_returns_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
|
||||
let messages = store.load("nonexistent");
|
||||
assert!(messages.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_sanitization() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
|
||||
// Keys with special chars should be sanitized
|
||||
store
|
||||
.append("slack/thread:123/user", &ChatMessage::user("test"))
|
||||
.unwrap();
|
||||
|
||||
let messages = store.load("slack/thread:123/user");
|
||||
assert_eq!(messages.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_sessions_returns_keys() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
|
||||
store
|
||||
.append("telegram_alice", &ChatMessage::user("hi"))
|
||||
.unwrap();
|
||||
store
|
||||
.append("discord_bob", &ChatMessage::user("hey"))
|
||||
.unwrap();
|
||||
|
||||
let mut sessions = store.list_sessions();
|
||||
sessions.sort();
|
||||
assert_eq!(sessions.len(), 2);
|
||||
assert!(sessions.contains(&"discord_bob".to_string()));
|
||||
assert!(sessions.contains(&"telegram_alice".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_is_truly_append_only() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
let key = "test_session";
|
||||
|
||||
store.append(key, &ChatMessage::user("msg1")).unwrap();
|
||||
store.append(key, &ChatMessage::user("msg2")).unwrap();
|
||||
|
||||
// Read raw file to verify append-only format
|
||||
let path = store.session_path(key);
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = content.trim().lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_drops_final_message() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
|
||||
store
|
||||
.append("rm_test", &ChatMessage::user("first"))
|
||||
.unwrap();
|
||||
store
|
||||
.append("rm_test", &ChatMessage::user("second"))
|
||||
.unwrap();
|
||||
|
||||
assert!(store.remove_last("rm_test").unwrap());
|
||||
let messages = store.load("rm_test");
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert_eq!(messages[0].content, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_empty_returns_false() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
assert!(!store.remove_last("nonexistent").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_removes_corrupt_lines() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
let key = "compact_test";
|
||||
|
||||
let path = store.session_path(key);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
writeln!(file, r#"{{"role":"user","content":"ok"}}"#).unwrap();
|
||||
writeln!(file, "corrupt line").unwrap();
|
||||
writeln!(file, r#"{{"role":"assistant","content":"hi"}}"#).unwrap();
|
||||
|
||||
store.compact(key).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(raw.trim().lines().count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_backend_trait_works_via_dyn() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
let backend: &dyn SessionBackend = &store;
|
||||
|
||||
backend
|
||||
.append("trait_test", &ChatMessage::user("hello"))
|
||||
.unwrap();
|
||||
let msgs = backend.load("trait_test");
|
||||
assert_eq!(msgs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_corrupt_lines_gracefully() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = SessionStore::new(tmp.path()).unwrap();
|
||||
let key = "corrupt_test";
|
||||
|
||||
// Write valid message + corrupt line + valid message
|
||||
let path = store.session_path(key);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
writeln!(file, r#"{{"role":"user","content":"hello"}}"#).unwrap();
|
||||
writeln!(file, "this is not valid json").unwrap();
|
||||
writeln!(file, r#"{{"role":"assistant","content":"world"}}"#).unwrap();
|
||||
|
||||
let messages = store.load(key);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(messages[0].content, "hello");
|
||||
assert_eq!(messages[1].content, "world");
|
||||
}
|
||||
}
|
||||
+69
-11
@@ -334,6 +334,13 @@ pub struct TelegramChannel {
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EditMessageResult {
|
||||
Success,
|
||||
NotModified,
|
||||
Failed(reqwest::StatusCode),
|
||||
}
|
||||
|
||||
impl TelegramChannel {
|
||||
pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
|
||||
let normalized_allowed = Self::normalize_allowed_users(allowed_users);
|
||||
@@ -540,6 +547,20 @@ impl TelegramChannel {
|
||||
format!("{}/bot{}/{method}", self.api_base, self.bot_token)
|
||||
}
|
||||
|
||||
async fn classify_edit_message_response(resp: reqwest::Response) -> EditMessageResult {
|
||||
if resp.status().is_success() {
|
||||
return EditMessageResult::Success;
|
||||
}
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
if body.contains("message is not modified") {
|
||||
return EditMessageResult::NotModified;
|
||||
}
|
||||
|
||||
EditMessageResult::Failed(status)
|
||||
}
|
||||
|
||||
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
|
||||
let resp = self.http_client().get(self.api_url("getMe")).send().await?;
|
||||
|
||||
@@ -730,7 +751,7 @@ impl TelegramChannel {
|
||||
|
||||
if let Some(identity) = bind_identity {
|
||||
self.add_allowed_identity_runtime(&identity);
|
||||
match self.persist_allowed_identity(&identity).await {
|
||||
match Box::pin(self.persist_allowed_identity(&identity)).await {
|
||||
Ok(()) => {
|
||||
let _ = self
|
||||
.send(&SendMessage::new(
|
||||
@@ -2374,11 +2395,17 @@ impl Channel for TelegramChannel {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
match Self::classify_edit_message_response(resp).await {
|
||||
EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
|
||||
EditMessageResult::Failed(status) => {
|
||||
tracing::debug!(
|
||||
status = ?status,
|
||||
"Telegram finalize_draft HTML edit failed; retrying without parse_mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown failed — retry without parse_mode
|
||||
// HTML failed — retry without parse_mode
|
||||
let plain_body = serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"message_id": id,
|
||||
@@ -2392,14 +2419,45 @@ impl Channel for TelegramChannel {
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
match Self::classify_edit_message_response(resp).await {
|
||||
EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
|
||||
EditMessageResult::Failed(status) => {
|
||||
tracing::warn!(
|
||||
status = ?status,
|
||||
"Telegram finalize_draft plain edit failed; attempting delete+send fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Edit failed entirely — fall back to new message
|
||||
tracing::warn!("Telegram finalize_draft edit failed; falling back to sendMessage");
|
||||
self.send_text_chunks(text, &chat_id, thread_id.as_deref())
|
||||
.await
|
||||
let delete_resp = self
|
||||
.client
|
||||
.post(self.api_url("deleteMessage"))
|
||||
.json(&serde_json::json!({
|
||||
"chat_id": chat_id,
|
||||
"message_id": id,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match delete_resp {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
self.send_text_chunks(text, &chat_id, thread_id.as_deref())
|
||||
.await
|
||||
}
|
||||
Ok(resp) => {
|
||||
tracing::warn!(
|
||||
status = ?resp.status(),
|
||||
"Telegram finalize_draft delete failed; skipping sendMessage to avoid duplicate"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Telegram finalize_draft delete request failed: {err}; skipping sendMessage to avoid duplicate"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
|
||||
@@ -2627,7 +2685,7 @@ Ensure only one `zeroclaw` process is using this bot token."
|
||||
} else if let Some(m) = self.try_parse_attachment_message(update).await {
|
||||
m
|
||||
} else {
|
||||
self.handle_unauthorized_message(update).await;
|
||||
Box::pin(self.handle_unauthorized_message(update)).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
|
||||
+802
-32
@@ -1,11 +1,19 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
|
||||
use crate::config::TranscriptionConfig;
|
||||
|
||||
/// Maximum upload size accepted by the Groq Whisper API (25 MB).
|
||||
/// Maximum upload size accepted by most Whisper-compatible APIs (25 MB).
|
||||
const MAX_AUDIO_BYTES: usize = 25 * 1024 * 1024;
|
||||
|
||||
/// Request timeout for transcription API calls (seconds).
|
||||
const TRANSCRIPTION_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
// ── Audio utilities ─────────────────────────────────────────────
|
||||
|
||||
/// Map file extension to MIME type for Whisper-compatible transcription APIs.
|
||||
fn mime_for_audio(extension: &str) -> Option<&'static str> {
|
||||
match extension.to_ascii_lowercase().as_str() {
|
||||
@@ -31,16 +39,51 @@ fn normalize_audio_filename(file_name: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transcribe audio bytes via a Whisper-compatible transcription API.
|
||||
/// Resolve the API key for voice transcription.
|
||||
///
|
||||
/// Returns the transcribed text on success. Requires `GROQ_API_KEY` in the
|
||||
/// environment. The caller is responsible for enforcing duration limits
|
||||
/// *before* downloading the file; this function enforces the byte-size cap.
|
||||
pub async fn transcribe_audio(
|
||||
audio_data: Vec<u8>,
|
||||
file_name: &str,
|
||||
config: &TranscriptionConfig,
|
||||
) -> Result<String> {
|
||||
/// Priority order:
|
||||
/// 1. Explicit `config.api_key` (if set and non-empty).
|
||||
/// 2. Provider-specific env var based on `api_url`:
|
||||
/// - URL contains "openai.com" -> `OPENAI_API_KEY`
|
||||
/// - URL contains "groq.com" -> `GROQ_API_KEY`
|
||||
/// 3. Fallback chain: `TRANSCRIPTION_API_KEY` -> `GROQ_API_KEY` -> `OPENAI_API_KEY`.
|
||||
fn resolve_transcription_api_key(config: &TranscriptionConfig) -> Result<String> {
|
||||
// 1. Explicit config key
|
||||
if let Some(ref key) = config.api_key {
|
||||
let trimmed = key.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Provider-specific env var based on API URL
|
||||
if config.api_url.contains("openai.com") {
|
||||
if let Ok(key) = std::env::var("OPENAI_API_KEY") {
|
||||
return Ok(key);
|
||||
}
|
||||
} else if config.api_url.contains("groq.com") {
|
||||
if let Ok(key) = std::env::var("GROQ_API_KEY") {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback chain
|
||||
for var in ["TRANSCRIPTION_API_KEY", "GROQ_API_KEY", "OPENAI_API_KEY"] {
|
||||
if let Ok(key) = std::env::var(var) {
|
||||
return Ok(key);
|
||||
}
|
||||
}
|
||||
|
||||
bail!(
|
||||
"No API key found for voice transcription — set one of: \
|
||||
transcription.api_key in config, TRANSCRIPTION_API_KEY, GROQ_API_KEY, or OPENAI_API_KEY"
|
||||
);
|
||||
}
|
||||
|
||||
/// Validate audio data and resolve MIME type from file name.
|
||||
///
|
||||
/// Returns `(normalized_filename, mime_type)` on success.
|
||||
fn validate_audio(audio_data: &[u8], file_name: &str) -> Result<(String, &'static str)> {
|
||||
if audio_data.len() > MAX_AUDIO_BYTES {
|
||||
bail!(
|
||||
"Audio file too large ({} bytes, max {MAX_AUDIO_BYTES})",
|
||||
@@ -59,33 +102,494 @@ pub async fn transcribe_audio(
|
||||
)
|
||||
})?;
|
||||
|
||||
let api_key = std::env::var("GROQ_API_KEY").context(
|
||||
"GROQ_API_KEY environment variable is not set — required for voice transcription",
|
||||
)?;
|
||||
Ok((normalized_name, mime))
|
||||
}
|
||||
|
||||
let client = crate::config::build_runtime_proxy_client("transcription.groq");
|
||||
// ── TranscriptionProvider trait ─────────────────────────────────
|
||||
|
||||
let file_part = Part::bytes(audio_data)
|
||||
.file_name(normalized_name)
|
||||
.mime_str(mime)?;
|
||||
/// Trait for speech-to-text provider implementations.
|
||||
#[async_trait]
|
||||
pub trait TranscriptionProvider: Send + Sync {
|
||||
/// Human-readable provider name (e.g. "groq", "openai").
|
||||
fn name(&self) -> &str;
|
||||
|
||||
let mut form = Form::new()
|
||||
.part("file", file_part)
|
||||
.text("model", config.model.clone())
|
||||
.text("response_format", "json");
|
||||
/// Transcribe raw audio bytes. `file_name` includes the extension for
|
||||
/// format detection (e.g. "voice.ogg").
|
||||
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String>;
|
||||
|
||||
if let Some(ref lang) = config.language {
|
||||
form = form.text("language", lang.clone());
|
||||
/// List of supported audio file extensions.
|
||||
fn supported_formats(&self) -> Vec<String> {
|
||||
vec![
|
||||
"flac", "mp3", "mpeg", "mpga", "mp4", "m4a", "ogg", "oga", "opus", "wav", "webm",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── GroqProvider ────────────────────────────────────────────────
|
||||
|
||||
/// Groq Whisper API provider (default, backward-compatible with existing config).
|
||||
pub struct GroqProvider {
|
||||
api_url: String,
|
||||
model: String,
|
||||
api_key: String,
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl GroqProvider {
|
||||
/// Build from the existing `TranscriptionConfig` fields.
|
||||
///
|
||||
/// Credential resolution order:
|
||||
/// 1. `config.api_key`
|
||||
/// 2. `GROQ_API_KEY` environment variable (backward compatibility)
|
||||
pub fn from_config(config: &TranscriptionConfig) -> Result<Self> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| {
|
||||
std::env::var("GROQ_API_KEY")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
})
|
||||
.context(
|
||||
"Missing transcription API key: set [transcription].api_key or GROQ_API_KEY environment variable",
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
api_url: config.api_url.clone(),
|
||||
model: config.model.clone(),
|
||||
api_key,
|
||||
language: config.language.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for GroqProvider {
|
||||
fn name(&self) -> &str {
|
||||
"groq"
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&config.api_url)
|
||||
.bearer_auth(&api_key)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send transcription request")?;
|
||||
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
|
||||
let (normalized_name, mime) = validate_audio(audio_data, file_name)?;
|
||||
|
||||
let client = crate::config::build_runtime_proxy_client("transcription.groq");
|
||||
|
||||
let file_part = Part::bytes(audio_data.to_vec())
|
||||
.file_name(normalized_name)
|
||||
.mime_str(mime)?;
|
||||
|
||||
let mut form = Form::new()
|
||||
.part("file", file_part)
|
||||
.text("model", self.model.clone())
|
||||
.text("response_format", "json");
|
||||
|
||||
if let Some(ref lang) = self.language {
|
||||
form = form.text("language", lang.clone());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
.post(&self.api_url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.multipart(form)
|
||||
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send transcription request to Groq")?;
|
||||
|
||||
parse_whisper_response(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenAiWhisperProvider ───────────────────────────────────────
|
||||
|
||||
/// OpenAI Whisper API provider.
|
||||
pub struct OpenAiWhisperProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl OpenAiWhisperProvider {
|
||||
pub fn from_config(config: &crate::config::OpenAiSttConfig) -> Result<Self> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("Missing OpenAI STT API key: set [transcription.openai].api_key")?;
|
||||
|
||||
Ok(Self {
|
||||
api_key,
|
||||
model: config.model.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for OpenAiWhisperProvider {
|
||||
fn name(&self) -> &str {
|
||||
"openai"
|
||||
}
|
||||
|
||||
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
|
||||
let (normalized_name, mime) = validate_audio(audio_data, file_name)?;
|
||||
|
||||
let client = crate::config::build_runtime_proxy_client("transcription.openai");
|
||||
|
||||
let file_part = Part::bytes(audio_data.to_vec())
|
||||
.file_name(normalized_name)
|
||||
.mime_str(mime)?;
|
||||
|
||||
let form = Form::new()
|
||||
.part("file", file_part)
|
||||
.text("model", self.model.clone())
|
||||
.text("response_format", "json");
|
||||
|
||||
let resp = client
|
||||
.post("https://api.openai.com/v1/audio/transcriptions")
|
||||
.bearer_auth(&self.api_key)
|
||||
.multipart(form)
|
||||
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send transcription request to OpenAI")?;
|
||||
|
||||
parse_whisper_response(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeepgramProvider ────────────────────────────────────────────
|
||||
|
||||
/// Deepgram STT API provider.
|
||||
pub struct DeepgramProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
impl DeepgramProvider {
|
||||
pub fn from_config(config: &crate::config::DeepgramSttConfig) -> Result<Self> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("Missing Deepgram API key: set [transcription.deepgram].api_key")?;
|
||||
|
||||
Ok(Self {
|
||||
api_key,
|
||||
model: config.model.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for DeepgramProvider {
|
||||
fn name(&self) -> &str {
|
||||
"deepgram"
|
||||
}
|
||||
|
||||
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
|
||||
let (_, mime) = validate_audio(audio_data, file_name)?;
|
||||
|
||||
let client = crate::config::build_runtime_proxy_client("transcription.deepgram");
|
||||
|
||||
let url = format!(
|
||||
"https://api.deepgram.com/v1/listen?model={}&punctuate=true",
|
||||
self.model
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Token {}", self.api_key))
|
||||
.header("Content-Type", mime)
|
||||
.body(audio_data.to_vec())
|
||||
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send transcription request to Deepgram")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Deepgram response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let error_msg = body["err_msg"]
|
||||
.as_str()
|
||||
.or_else(|| body["error"].as_str())
|
||||
.unwrap_or("unknown error");
|
||||
bail!("Deepgram API error ({}): {}", status, error_msg);
|
||||
}
|
||||
|
||||
let text = body["results"]["channels"][0]["alternatives"][0]["transcript"]
|
||||
.as_str()
|
||||
.context("Deepgram response missing transcript field")?
|
||||
.to_string();
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── AssemblyAiProvider ──────────────────────────────────────────
|
||||
|
||||
/// AssemblyAI STT API provider.
|
||||
pub struct AssemblyAiProvider {
|
||||
api_key: String,
|
||||
}
|
||||
|
||||
impl AssemblyAiProvider {
|
||||
pub fn from_config(config: &crate::config::AssemblyAiSttConfig) -> Result<Self> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("Missing AssemblyAI API key: set [transcription.assemblyai].api_key")?;
|
||||
|
||||
Ok(Self { api_key })
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for AssemblyAiProvider {
|
||||
fn name(&self) -> &str {
|
||||
"assemblyai"
|
||||
}
|
||||
|
||||
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
|
||||
let (_, _) = validate_audio(audio_data, file_name)?;
|
||||
|
||||
let client = crate::config::build_runtime_proxy_client("transcription.assemblyai");
|
||||
|
||||
// Step 1: Upload the audio file.
|
||||
let upload_resp = client
|
||||
.post("https://api.assemblyai.com/v2/upload")
|
||||
.header("Authorization", &self.api_key)
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.body(audio_data.to_vec())
|
||||
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to upload audio to AssemblyAI")?;
|
||||
|
||||
let upload_status = upload_resp.status();
|
||||
let upload_body: serde_json::Value = upload_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse AssemblyAI upload response")?;
|
||||
|
||||
if !upload_status.is_success() {
|
||||
let error_msg = upload_body["error"].as_str().unwrap_or("unknown error");
|
||||
bail!("AssemblyAI upload error ({}): {}", upload_status, error_msg);
|
||||
}
|
||||
|
||||
let upload_url = upload_body["upload_url"]
|
||||
.as_str()
|
||||
.context("AssemblyAI upload response missing 'upload_url'")?;
|
||||
|
||||
// Step 2: Create transcription job.
|
||||
let transcript_req = serde_json::json!({
|
||||
"audio_url": upload_url,
|
||||
});
|
||||
|
||||
let create_resp = client
|
||||
.post("https://api.assemblyai.com/v2/transcript")
|
||||
.header("Authorization", &self.api_key)
|
||||
.json(&transcript_req)
|
||||
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to create AssemblyAI transcription")?;
|
||||
|
||||
let create_status = create_resp.status();
|
||||
let create_body: serde_json::Value = create_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse AssemblyAI create response")?;
|
||||
|
||||
if !create_status.is_success() {
|
||||
let error_msg = create_body["error"].as_str().unwrap_or("unknown error");
|
||||
bail!(
|
||||
"AssemblyAI transcription error ({}): {}",
|
||||
create_status,
|
||||
error_msg
|
||||
);
|
||||
}
|
||||
|
||||
let transcript_id = create_body["id"]
|
||||
.as_str()
|
||||
.context("AssemblyAI response missing 'id'")?;
|
||||
|
||||
// Step 3: Poll for completion.
|
||||
let poll_url = format!("https://api.assemblyai.com/v2/transcript/{transcript_id}");
|
||||
let poll_interval = std::time::Duration::from_secs(3);
|
||||
let poll_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(180);
|
||||
|
||||
while tokio::time::Instant::now() < poll_deadline {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
|
||||
let poll_resp = client
|
||||
.get(&poll_url)
|
||||
.header("Authorization", &self.api_key)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to poll AssemblyAI transcription")?;
|
||||
|
||||
let poll_status = poll_resp.status();
|
||||
let poll_body: serde_json::Value = poll_resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse AssemblyAI poll response")?;
|
||||
|
||||
if !poll_status.is_success() {
|
||||
let error_msg = poll_body["error"].as_str().unwrap_or("unknown poll error");
|
||||
bail!("AssemblyAI poll error ({}): {}", poll_status, error_msg);
|
||||
}
|
||||
|
||||
let status_str = poll_body["status"].as_str().unwrap_or("unknown");
|
||||
|
||||
match status_str {
|
||||
"completed" => {
|
||||
let text = poll_body["text"]
|
||||
.as_str()
|
||||
.context("AssemblyAI response missing 'text'")?
|
||||
.to_string();
|
||||
return Ok(text);
|
||||
}
|
||||
"error" => {
|
||||
let error_msg = poll_body["error"]
|
||||
.as_str()
|
||||
.unwrap_or("unknown transcription error");
|
||||
bail!("AssemblyAI transcription failed: {}", error_msg);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
bail!("AssemblyAI transcription timed out after 180s")
|
||||
}
|
||||
}
|
||||
|
||||
// ── GoogleSttProvider ───────────────────────────────────────────
|
||||
|
||||
/// Google Cloud Speech-to-Text API provider.
|
||||
pub struct GoogleSttProvider {
|
||||
api_key: String,
|
||||
language_code: String,
|
||||
}
|
||||
|
||||
impl GoogleSttProvider {
|
||||
pub fn from_config(config: &crate::config::GoogleSttConfig) -> Result<Self> {
|
||||
let api_key = config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.context("Missing Google STT API key: set [transcription.google].api_key")?;
|
||||
|
||||
Ok(Self {
|
||||
api_key,
|
||||
language_code: config.language_code.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TranscriptionProvider for GoogleSttProvider {
|
||||
fn name(&self) -> &str {
|
||||
"google"
|
||||
}
|
||||
|
||||
fn supported_formats(&self) -> Vec<String> {
|
||||
// Google Cloud STT supports a subset of formats.
|
||||
vec!["flac", "wav", "ogg", "opus", "mp3", "webm"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
|
||||
let (normalized_name, _) = validate_audio(audio_data, file_name)?;
|
||||
|
||||
let client = crate::config::build_runtime_proxy_client("transcription.google");
|
||||
|
||||
let encoding = match normalized_name
|
||||
.rsplit_once('.')
|
||||
.map(|(_, e)| e.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("flac") => "FLAC",
|
||||
Some("wav") => "LINEAR16",
|
||||
Some("ogg" | "opus") => "OGG_OPUS",
|
||||
Some("mp3") => "MP3",
|
||||
Some("webm") => "WEBM_OPUS",
|
||||
Some(ext) => bail!("Google STT does not support '.{ext}' input"),
|
||||
None => bail!("Google STT requires a file extension"),
|
||||
};
|
||||
|
||||
let audio_content =
|
||||
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, audio_data);
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"config": {
|
||||
"encoding": encoding,
|
||||
"languageCode": &self.language_code,
|
||||
"enableAutomaticPunctuation": true,
|
||||
},
|
||||
"audio": {
|
||||
"content": audio_content,
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!(
|
||||
"https://speech.googleapis.com/v1/speech:recognize?key={}",
|
||||
self.api_key
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&request_body)
|
||||
.timeout(std::time::Duration::from_secs(TRANSCRIPTION_TIMEOUT_SECS))
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send transcription request to Google STT")?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse Google STT response")?;
|
||||
|
||||
if !status.is_success() {
|
||||
let error_msg = body["error"]["message"].as_str().unwrap_or("unknown error");
|
||||
bail!("Google STT API error ({}): {}", status, error_msg);
|
||||
}
|
||||
|
||||
let text = body["results"][0]["alternatives"][0]["transcript"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared response parsing ─────────────────────────────────────
|
||||
|
||||
/// Parse a standard Whisper-compatible JSON response (`{ "text": "..." }`).
|
||||
async fn parse_whisper_response(resp: reqwest::Response) -> Result<String> {
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
@@ -105,6 +609,128 @@ pub async fn transcribe_audio(
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
// ── TranscriptionManager ────────────────────────────────────────
|
||||
|
||||
/// Manages multiple STT providers and routes transcription requests.
|
||||
pub struct TranscriptionManager {
|
||||
providers: HashMap<String, Box<dyn TranscriptionProvider>>,
|
||||
default_provider: String,
|
||||
}
|
||||
|
||||
impl TranscriptionManager {
|
||||
/// Build a `TranscriptionManager` from config.
|
||||
///
|
||||
/// Always attempts to register the Groq provider from existing config fields.
|
||||
/// Additional providers are registered when their config sections are present.
|
||||
///
|
||||
/// Provider keys with missing API keys are silently skipped — the error
|
||||
/// surfaces at transcribe-time so callers that target a different default
|
||||
/// provider are not blocked.
|
||||
pub fn new(config: &TranscriptionConfig) -> Result<Self> {
|
||||
let mut providers: HashMap<String, Box<dyn TranscriptionProvider>> = HashMap::new();
|
||||
|
||||
if let Ok(groq) = GroqProvider::from_config(config) {
|
||||
providers.insert("groq".to_string(), Box::new(groq));
|
||||
}
|
||||
|
||||
if let Some(ref openai_cfg) = config.openai {
|
||||
if let Ok(p) = OpenAiWhisperProvider::from_config(openai_cfg) {
|
||||
providers.insert("openai".to_string(), Box::new(p));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref deepgram_cfg) = config.deepgram {
|
||||
if let Ok(p) = DeepgramProvider::from_config(deepgram_cfg) {
|
||||
providers.insert("deepgram".to_string(), Box::new(p));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref assemblyai_cfg) = config.assemblyai {
|
||||
if let Ok(p) = AssemblyAiProvider::from_config(assemblyai_cfg) {
|
||||
providers.insert("assemblyai".to_string(), Box::new(p));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref google_cfg) = config.google {
|
||||
if let Ok(p) = GoogleSttProvider::from_config(google_cfg) {
|
||||
providers.insert("google".to_string(), Box::new(p));
|
||||
}
|
||||
}
|
||||
|
||||
let default_provider = config.default_provider.clone();
|
||||
|
||||
if config.enabled && !providers.contains_key(&default_provider) {
|
||||
let available: Vec<&str> = providers.keys().map(|k| k.as_str()).collect();
|
||||
bail!(
|
||||
"Default transcription provider '{}' is not configured. Available: {available:?}",
|
||||
default_provider
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
providers,
|
||||
default_provider,
|
||||
})
|
||||
}
|
||||
|
||||
/// Transcribe audio using the default provider.
|
||||
pub async fn transcribe(&self, audio_data: &[u8], file_name: &str) -> Result<String> {
|
||||
self.transcribe_with_provider(audio_data, file_name, &self.default_provider)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Transcribe audio using a specific named provider.
|
||||
pub async fn transcribe_with_provider(
|
||||
&self,
|
||||
audio_data: &[u8],
|
||||
file_name: &str,
|
||||
provider: &str,
|
||||
) -> Result<String> {
|
||||
let p = self.providers.get(provider).ok_or_else(|| {
|
||||
let available: Vec<&str> = self.providers.keys().map(|k| k.as_str()).collect();
|
||||
anyhow::anyhow!(
|
||||
"Transcription provider '{provider}' not configured. Available: {available:?}"
|
||||
)
|
||||
})?;
|
||||
|
||||
p.transcribe(audio_data, file_name).await
|
||||
}
|
||||
|
||||
/// List registered provider names.
|
||||
pub fn available_providers(&self) -> Vec<&str> {
|
||||
self.providers.keys().map(|k| k.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backward-compatible convenience function ────────────────────
|
||||
|
||||
/// Transcribe audio bytes via a Whisper-compatible transcription API.
|
||||
///
|
||||
/// Returns the transcribed text on success.
|
||||
///
|
||||
/// This is the backward-compatible entry point that preserves the original
|
||||
/// function signature. It uses the Groq provider directly, matching the
|
||||
/// original single-provider behavior.
|
||||
///
|
||||
/// Credential resolution order:
|
||||
/// 1. `config.transcription.api_key`
|
||||
/// 2. `GROQ_API_KEY` environment variable (backward compatibility)
|
||||
///
|
||||
/// The caller is responsible for enforcing duration limits *before* downloading
|
||||
/// the file; this function enforces the byte-size cap.
|
||||
pub async fn transcribe_audio(
|
||||
audio_data: Vec<u8>,
|
||||
file_name: &str,
|
||||
config: &TranscriptionConfig,
|
||||
) -> Result<String> {
|
||||
// Validate audio before resolving credentials so that size/format errors
|
||||
// are reported before missing-key errors (preserves original behavior).
|
||||
validate_audio(&audio_data, file_name)?;
|
||||
|
||||
let groq = GroqProvider::from_config(config)?;
|
||||
groq.transcribe(&audio_data, file_name).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -125,8 +751,10 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_missing_api_key() {
|
||||
// Ensure the key is absent for this test
|
||||
// Ensure all candidate keys are absent for this test.
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
std::env::remove_var("OPENAI_API_KEY");
|
||||
std::env::remove_var("TRANSCRIPTION_API_KEY");
|
||||
|
||||
let data = vec![0u8; 100];
|
||||
let config = TranscriptionConfig::default();
|
||||
@@ -135,11 +763,29 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("GROQ_API_KEY"),
|
||||
err.to_string().contains("transcription API key"),
|
||||
"expected missing-key error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_config_api_key_without_groq_env() {
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
|
||||
let data = vec![0u8; 100];
|
||||
let mut config = TranscriptionConfig::default();
|
||||
config.api_key = Some("transcription-key".to_string());
|
||||
|
||||
// Keep invalid extension so we fail before network, but after key resolution.
|
||||
let err = transcribe_audio(data, "recording.aac", &config)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("Unsupported audio format"),
|
||||
"expected unsupported-format error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mime_for_audio_maps_accepted_formats() {
|
||||
let cases = [
|
||||
@@ -215,4 +861,128 @@ mod tests {
|
||||
"error should mention the rejected extension, got: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── TranscriptionManager tests ──────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn manager_creation_with_default_config() {
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
|
||||
let config = TranscriptionConfig::default();
|
||||
let manager = TranscriptionManager::new(&config).unwrap();
|
||||
assert_eq!(manager.default_provider, "groq");
|
||||
// Groq won't be registered without a key.
|
||||
assert!(manager.providers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_registers_groq_with_key() {
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
|
||||
let mut config = TranscriptionConfig::default();
|
||||
config.api_key = Some("test-groq-key".to_string());
|
||||
|
||||
let manager = TranscriptionManager::new(&config).unwrap();
|
||||
assert!(manager.providers.contains_key("groq"));
|
||||
assert_eq!(manager.providers["groq"].name(), "groq");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_registers_multiple_providers() {
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
|
||||
let mut config = TranscriptionConfig::default();
|
||||
config.api_key = Some("test-groq-key".to_string());
|
||||
config.openai = Some(crate::config::OpenAiSttConfig {
|
||||
api_key: Some("test-openai-key".to_string()),
|
||||
model: "whisper-1".to_string(),
|
||||
});
|
||||
config.deepgram = Some(crate::config::DeepgramSttConfig {
|
||||
api_key: Some("test-deepgram-key".to_string()),
|
||||
model: "nova-2".to_string(),
|
||||
});
|
||||
|
||||
let manager = TranscriptionManager::new(&config).unwrap();
|
||||
assert!(manager.providers.contains_key("groq"));
|
||||
assert!(manager.providers.contains_key("openai"));
|
||||
assert!(manager.providers.contains_key("deepgram"));
|
||||
assert_eq!(manager.available_providers().len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manager_rejects_unconfigured_provider() {
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
|
||||
let mut config = TranscriptionConfig::default();
|
||||
config.api_key = Some("test-groq-key".to_string());
|
||||
|
||||
let manager = TranscriptionManager::new(&config).unwrap();
|
||||
let err = manager
|
||||
.transcribe_with_provider(&[0u8; 100], "test.ogg", "nonexistent")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("not configured"),
|
||||
"expected not-configured error, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_default_provider_from_config() {
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
|
||||
let mut config = TranscriptionConfig::default();
|
||||
config.default_provider = "openai".to_string();
|
||||
config.openai = Some(crate::config::OpenAiSttConfig {
|
||||
api_key: Some("test-openai-key".to_string()),
|
||||
model: "whisper-1".to_string(),
|
||||
});
|
||||
|
||||
let manager = TranscriptionManager::new(&config).unwrap();
|
||||
assert_eq!(manager.default_provider, "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_audio_rejects_oversized() {
|
||||
let big = vec![0u8; MAX_AUDIO_BYTES + 1];
|
||||
let err = validate_audio(&big, "test.ogg").unwrap_err();
|
||||
assert!(err.to_string().contains("too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_audio_rejects_unsupported_format() {
|
||||
let data = vec![0u8; 100];
|
||||
let err = validate_audio(&data, "test.aac").unwrap_err();
|
||||
assert!(err.to_string().contains("Unsupported audio format"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_audio_accepts_supported_format() {
|
||||
let data = vec![0u8; 100];
|
||||
let (name, mime) = validate_audio(&data, "test.ogg").unwrap();
|
||||
assert_eq!(name, "test.ogg");
|
||||
assert_eq!(mime, "audio/ogg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_audio_normalizes_oga() {
|
||||
let data = vec![0u8; 100];
|
||||
let (name, mime) = validate_audio(&data, "voice.oga").unwrap();
|
||||
assert_eq!(name, "voice.ogg");
|
||||
assert_eq!(mime, "audio/ogg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backward_compat_config_defaults_unchanged() {
|
||||
let config = TranscriptionConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert!(config.api_key.is_none());
|
||||
assert!(config.api_url.contains("groq.com"));
|
||||
assert_eq!(config.model, "whisper-large-v3-turbo");
|
||||
assert_eq!(config.default_provider, "groq");
|
||||
assert!(config.openai.is_none());
|
||||
assert!(config.deepgram.is_none());
|
||||
assert!(config.assemblyai.is_none());
|
||||
assert!(config.google.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ impl TtsProvider for OpenAiTtsProvider {
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"speed": self.speed,
|
||||
"response_format": "opus",
|
||||
});
|
||||
|
||||
let resp = self
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TWITTER_API_BASE: &str = "https://api.x.com/2";
|
||||
|
||||
/// X/Twitter channel — uses the Twitter API v2 with OAuth 2.0 Bearer Token
|
||||
/// for sending tweets/DMs and filtered stream for receiving mentions.
|
||||
pub struct TwitterChannel {
|
||||
bearer_token: String,
|
||||
allowed_users: Vec<String>,
|
||||
/// Message deduplication set.
|
||||
dedup: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
/// Deduplication set capacity — evict half of entries when full.
|
||||
const DEDUP_CAPACITY: usize = 10_000;
|
||||
|
||||
impl TwitterChannel {
|
||||
pub fn new(bearer_token: String, allowed_users: Vec<String>) -> Self {
|
||||
Self {
|
||||
bearer_token,
|
||||
allowed_users,
|
||||
dedup: Arc::new(RwLock::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.twitter")
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||
}
|
||||
|
||||
/// Check and insert tweet ID for deduplication.
|
||||
async fn is_duplicate(&self, tweet_id: &str) -> bool {
|
||||
if tweet_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut dedup = self.dedup.write().await;
|
||||
|
||||
if dedup.contains(tweet_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if dedup.len() >= DEDUP_CAPACITY {
|
||||
let to_remove: Vec<String> = dedup.iter().take(DEDUP_CAPACITY / 2).cloned().collect();
|
||||
for key in to_remove {
|
||||
dedup.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
dedup.insert(tweet_id.to_string());
|
||||
false
|
||||
}
|
||||
|
||||
/// Get the authenticated user's ID for filtered stream rules.
|
||||
async fn get_authenticated_user_id(&self) -> anyhow::Result<String> {
|
||||
let resp = self
|
||||
.http_client()
|
||||
.get(format!("{TWITTER_API_BASE}/users/me"))
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Twitter users/me failed ({status}): {err}");
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
let user_id = data
|
||||
.get("data")
|
||||
.and_then(|d| d.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing user id in Twitter response"))?
|
||||
.to_string();
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
/// Send a reply tweet.
|
||||
async fn create_tweet(
|
||||
&self,
|
||||
text: &str,
|
||||
reply_tweet_id: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut body = json!({ "text": text });
|
||||
|
||||
if let Some(reply_id) = reply_tweet_id {
|
||||
body["reply"] = json!({ "in_reply_to_tweet_id": reply_id });
|
||||
}
|
||||
|
||||
let resp = self
|
||||
.http_client()
|
||||
.post(format!("{TWITTER_API_BASE}/tweets"))
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Twitter create tweet failed ({status}): {err}");
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
let tweet_id = data
|
||||
.get("data")
|
||||
.and_then(|d| d.get("id"))
|
||||
.and_then(|id| id.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
Ok(tweet_id)
|
||||
}
|
||||
|
||||
/// Send a DM to a user.
|
||||
async fn send_dm(&self, recipient_id: &str, text: &str) -> anyhow::Result<()> {
|
||||
let body = json!({
|
||||
"text": text,
|
||||
});
|
||||
|
||||
let resp = self
|
||||
.http_client()
|
||||
.post(format!(
|
||||
"{TWITTER_API_BASE}/dm_conversations/with/{recipient_id}/messages"
|
||||
))
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Twitter DM send failed ({status}): {err}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for TwitterChannel {
|
||||
fn name(&self) -> &str {
|
||||
"twitter"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
// recipient format: "dm:{user_id}" for DMs, "tweet:{tweet_id}" for replies
|
||||
if let Some(user_id) = message.recipient.strip_prefix("dm:") {
|
||||
// Twitter API enforces a 280 char limit on tweets but DMs can be up to 10000.
|
||||
self.send_dm(user_id, &message.content).await
|
||||
} else if let Some(tweet_id) = message.recipient.strip_prefix("tweet:") {
|
||||
// Split long replies into tweet threads (280 char limit).
|
||||
let chunks = split_tweet_text(&message.content, 280);
|
||||
let mut reply_to = tweet_id.to_string();
|
||||
for chunk in chunks {
|
||||
reply_to = self.create_tweet(&chunk, Some(&reply_to)).await?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
// Default: treat as tweet reply
|
||||
let chunks = split_tweet_text(&message.content, 280);
|
||||
let mut reply_to = message.recipient.clone();
|
||||
for chunk in chunks {
|
||||
reply_to = self.create_tweet(&chunk, Some(&reply_to)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
tracing::info!("Twitter: authenticating...");
|
||||
let bot_user_id = self.get_authenticated_user_id().await?;
|
||||
tracing::info!("Twitter: authenticated as user {bot_user_id}");
|
||||
|
||||
// Poll mentions timeline (filtered stream requires elevated access).
|
||||
// Using mentions timeline polling as a more accessible approach.
|
||||
let mut since_id: Option<String> = None;
|
||||
let poll_interval = std::time::Duration::from_secs(15);
|
||||
|
||||
loop {
|
||||
let mut url = format!(
|
||||
"{TWITTER_API_BASE}/users/{bot_user_id}/mentions?tweet.fields=author_id,conversation_id,created_at&expansions=author_id&max_results=20"
|
||||
);
|
||||
|
||||
if let Some(ref id) = since_id {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(url, "&since_id={id}");
|
||||
}
|
||||
|
||||
match self
|
||||
.http_client()
|
||||
.get(&url)
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
let data: serde_json::Value = match resp.json().await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::warn!("Twitter: failed to parse mentions response: {e}");
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(tweets) = data.get("data").and_then(|d| d.as_array()) {
|
||||
// Build user lookup map from includes
|
||||
let user_map: std::collections::HashMap<String, String> = data
|
||||
.get("includes")
|
||||
.and_then(|i| i.get("users"))
|
||||
.and_then(|u| u.as_array())
|
||||
.map(|users| {
|
||||
users
|
||||
.iter()
|
||||
.filter_map(|u| {
|
||||
let id = u.get("id")?.as_str()?.to_string();
|
||||
let username = u.get("username")?.as_str()?.to_string();
|
||||
Some((id, username))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Process tweets in chronological order (oldest first)
|
||||
for tweet in tweets.iter().rev() {
|
||||
let tweet_id = tweet.get("id").and_then(|i| i.as_str()).unwrap_or("");
|
||||
let author_id = tweet
|
||||
.get("author_id")
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("");
|
||||
let text = tweet.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
// Skip own tweets
|
||||
if author_id == bot_user_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.is_duplicate(tweet_id).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let username = user_map
|
||||
.get(author_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| author_id.to_string());
|
||||
|
||||
if !self.is_user_allowed(&username) && !self.is_user_allowed(author_id)
|
||||
{
|
||||
tracing::debug!(
|
||||
"Twitter: ignoring mention from unauthorized user: {username}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strip the @mention from the text
|
||||
let clean_text = strip_at_mention(text, &bot_user_id);
|
||||
|
||||
if clean_text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let reply_target = format!("tweet:{tweet_id}");
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: username,
|
||||
reply_target,
|
||||
content: clean_text,
|
||||
channel: "twitter".to_string(),
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
thread_ts: tweet
|
||||
.get("conversation_id")
|
||||
.and_then(|c| c.as_str())
|
||||
.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
if tx.send(channel_msg).await.is_err() {
|
||||
tracing::warn!("Twitter: message channel closed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Track newest ID for pagination
|
||||
if since_id.as_deref().map_or(true, |s| tweet_id > s) {
|
||||
since_id = Some(tweet_id.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update newest_id from meta
|
||||
if let Some(newest) = data
|
||||
.get("meta")
|
||||
.and_then(|m| m.get("newest_id"))
|
||||
.and_then(|n| n.as_str())
|
||||
{
|
||||
since_id = Some(newest.to_string());
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 429 {
|
||||
// Rate limited — back off
|
||||
tracing::warn!("Twitter: rate limited, backing off 60s");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
|
||||
continue;
|
||||
}
|
||||
let err = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!("Twitter: mentions request failed ({status}): {err}");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Twitter: mentions request error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
self.get_authenticated_user_id().await.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip @mention from the beginning of a tweet text.
|
||||
fn strip_at_mention(text: &str, _bot_user_id: &str) -> String {
|
||||
// Remove all leading @mentions (Twitter includes @bot_name at start of replies)
|
||||
let mut result = text;
|
||||
while let Some(rest) = result.strip_prefix('@') {
|
||||
// Skip past the username (until whitespace or end)
|
||||
match rest.find(char::is_whitespace) {
|
||||
Some(idx) => result = rest[idx..].trim_start(),
|
||||
None => return String::new(),
|
||||
}
|
||||
}
|
||||
result.to_string()
|
||||
}
|
||||
|
||||
/// Split text into tweet-sized chunks, breaking at word boundaries.
|
||||
fn split_tweet_text(text: &str, max_len: usize) -> Vec<String> {
|
||||
if text.len() <= max_len {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut remaining = text;
|
||||
|
||||
while !remaining.is_empty() {
|
||||
if remaining.len() <= max_len {
|
||||
chunks.push(remaining.to_string());
|
||||
break;
|
||||
}
|
||||
|
||||
// Find last space within limit
|
||||
let split_at = remaining[..max_len].rfind(' ').unwrap_or(max_len);
|
||||
|
||||
chunks.push(remaining[..split_at].to_string());
|
||||
remaining = remaining[split_at..].trim_start();
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_name() {
|
||||
let ch = TwitterChannel::new("token".into(), vec![]);
|
||||
assert_eq!(ch.name(), "twitter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_allowed_wildcard() {
|
||||
let ch = TwitterChannel::new("token".into(), vec!["*".into()]);
|
||||
assert!(ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_allowed_specific() {
|
||||
let ch = TwitterChannel::new("token".into(), vec!["user123".into()]);
|
||||
assert!(ch.is_user_allowed("user123"));
|
||||
assert!(!ch.is_user_allowed("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_denied_empty() {
|
||||
let ch = TwitterChannel::new("token".into(), vec![]);
|
||||
assert!(!ch.is_user_allowed("anyone"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dedup() {
|
||||
let ch = TwitterChannel::new("token".into(), vec![]);
|
||||
assert!(!ch.is_duplicate("tweet1").await);
|
||||
assert!(ch.is_duplicate("tweet1").await);
|
||||
assert!(!ch.is_duplicate("tweet2").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_dedup_empty_id() {
|
||||
let ch = TwitterChannel::new("token".into(), vec![]);
|
||||
assert!(!ch.is_duplicate("").await);
|
||||
assert!(!ch.is_duplicate("").await);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_at_mention_single() {
|
||||
assert_eq!(strip_at_mention("@bot hello world", "123"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_at_mention_multiple() {
|
||||
assert_eq!(strip_at_mention("@bot @other hello", "123"), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_at_mention_only() {
|
||||
assert_eq!(strip_at_mention("@bot", "123"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_at_mention_no_mention() {
|
||||
assert_eq!(strip_at_mention("hello world", "123"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_tweet_text_short() {
|
||||
let chunks = split_tweet_text("hello", 280);
|
||||
assert_eq!(chunks, vec!["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_tweet_text_long() {
|
||||
let text = "a ".repeat(200);
|
||||
let chunks = split_tweet_text(text.trim(), 280);
|
||||
assert!(chunks.len() > 1);
|
||||
for chunk in &chunks {
|
||||
assert!(chunk.len() <= 280);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_split_tweet_text_no_spaces() {
|
||||
let text = "a".repeat(300);
|
||||
let chunks = split_tweet_text(&text, 280);
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[0].len(), 280);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serde() {
|
||||
let toml_str = r#"
|
||||
bearer_token = "AAAA"
|
||||
allowed_users = ["user1"]
|
||||
"#;
|
||||
let config: crate::config::schema::TwitterConfig = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(config.bearer_token, "AAAA");
|
||||
assert_eq!(config.allowed_users, vec!["user1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_serde_defaults() {
|
||||
let toml_str = r#"
|
||||
bearer_token = "tok"
|
||||
"#;
|
||||
let config: crate::config::schema::TwitterConfig = toml::from_str(toml_str).unwrap();
|
||||
assert!(config.allowed_users.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use anyhow::{bail, Result};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Generic Webhook channel — receives messages via HTTP POST and sends replies
|
||||
/// to a configurable outbound URL. This is the "universal adapter" for any system
|
||||
/// that supports webhooks.
|
||||
pub struct WebhookChannel {
|
||||
listen_port: u16,
|
||||
listen_path: String,
|
||||
send_url: Option<String>,
|
||||
send_method: String,
|
||||
auth_header: Option<String>,
|
||||
secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Incoming webhook payload format.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct IncomingWebhook {
|
||||
sender: String,
|
||||
content: String,
|
||||
#[serde(default)]
|
||||
thread_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Outgoing webhook payload format.
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OutgoingWebhook {
|
||||
content: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thread_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
recipient: Option<String>,
|
||||
}
|
||||
|
||||
impl WebhookChannel {
|
||||
pub fn new(
|
||||
listen_port: u16,
|
||||
listen_path: Option<String>,
|
||||
send_url: Option<String>,
|
||||
send_method: Option<String>,
|
||||
auth_header: Option<String>,
|
||||
secret: Option<String>,
|
||||
) -> Self {
|
||||
let path = listen_path.unwrap_or_else(|| "/webhook".to_string());
|
||||
// Ensure path starts with /
|
||||
let listen_path = if path.starts_with('/') {
|
||||
path
|
||||
} else {
|
||||
format!("/{path}")
|
||||
};
|
||||
|
||||
Self {
|
||||
listen_port,
|
||||
listen_path,
|
||||
send_url,
|
||||
send_method: send_method
|
||||
.unwrap_or_else(|| "POST".to_string())
|
||||
.to_uppercase(),
|
||||
auth_header,
|
||||
secret,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.webhook")
|
||||
}
|
||||
|
||||
/// Verify an incoming request's signature if a secret is configured.
|
||||
fn verify_signature(&self, body: &[u8], signature: Option<&str>) -> bool {
|
||||
let Some(ref secret) = self.secret else {
|
||||
return true; // No secret configured, accept all
|
||||
};
|
||||
|
||||
let Some(sig) = signature else {
|
||||
return false; // Secret is set but no signature header provided
|
||||
};
|
||||
|
||||
// HMAC-SHA256 verification
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(body);
|
||||
|
||||
// Signature should be hex-encoded
|
||||
let Ok(expected) = hex::decode(sig.trim_start_matches("sha256=")) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
mac.verify_slice(&expected).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for WebhookChannel {
|
||||
fn name(&self) -> &str {
|
||||
"webhook"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||
let Some(ref send_url) = self.send_url else {
|
||||
tracing::debug!("Webhook channel: no send_url configured, skipping outbound message");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let client = self.http_client();
|
||||
let payload = OutgoingWebhook {
|
||||
content: message.content.clone(),
|
||||
thread_id: message.thread_ts.clone(),
|
||||
recipient: if message.recipient.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(message.recipient.clone())
|
||||
},
|
||||
};
|
||||
|
||||
let mut request = match self.send_method.as_str() {
|
||||
"PUT" => client.put(send_url),
|
||||
_ => client.post(send_url),
|
||||
};
|
||||
|
||||
if let Some(ref auth) = self.auth_header {
|
||||
request = request.header("Authorization", auth);
|
||||
}
|
||||
|
||||
let resp = request.json(&payload).send().await?;
|
||||
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response: {e}>"));
|
||||
bail!("Webhook send failed ({status}): {body}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
use portable_atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let counter = Arc::new(AtomicU64::new(0));
|
||||
|
||||
struct WebhookState {
|
||||
tx: tokio::sync::mpsc::Sender<ChannelMessage>,
|
||||
secret: Option<String>,
|
||||
counter: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
let state = Arc::new(WebhookState {
|
||||
tx: tx.clone(),
|
||||
secret: self.secret.clone(),
|
||||
counter: counter.clone(),
|
||||
});
|
||||
|
||||
let listen_path = self.listen_path.clone();
|
||||
|
||||
async fn handle_webhook(
|
||||
State(state): State<Arc<WebhookState>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> StatusCode {
|
||||
// Verify signature if secret is configured
|
||||
if let Some(ref secret) = state.secret {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let signature = headers
|
||||
.get("x-webhook-signature")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let valid = if let Some(sig) = signature {
|
||||
if let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) {
|
||||
mac.update(&body);
|
||||
let expected =
|
||||
hex::decode(sig.trim_start_matches("sha256=")).unwrap_or_default();
|
||||
mac.verify_slice(&expected).is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !valid {
|
||||
tracing::warn!("Webhook: invalid signature, rejecting request");
|
||||
return StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
}
|
||||
|
||||
let payload: IncomingWebhook = match serde_json::from_slice(&body) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
tracing::warn!("Webhook: invalid JSON payload: {e}");
|
||||
return StatusCode::BAD_REQUEST;
|
||||
}
|
||||
};
|
||||
|
||||
if payload.content.is_empty() {
|
||||
return StatusCode::BAD_REQUEST;
|
||||
}
|
||||
|
||||
let seq = state.counter.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let reply_target = payload
|
||||
.thread_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| payload.sender.clone());
|
||||
|
||||
let msg = ChannelMessage {
|
||||
id: format!("webhook_{seq}"),
|
||||
sender: payload.sender,
|
||||
reply_target,
|
||||
content: payload.content,
|
||||
channel: "webhook".to_string(),
|
||||
timestamp,
|
||||
thread_ts: payload.thread_id,
|
||||
};
|
||||
|
||||
if state.tx.send(msg).await.is_err() {
|
||||
return StatusCode::SERVICE_UNAVAILABLE;
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
let app = Router::new()
|
||||
.route(&listen_path, post(handle_webhook))
|
||||
.with_state(state);
|
||||
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], self.listen_port));
|
||||
tracing::info!(
|
||||
"Webhook channel listening on http://0.0.0.0:{}{} ...",
|
||||
self.listen_port,
|
||||
self.listen_path
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Webhook server error: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
// Webhook channel is healthy if the port can be bound (basic check).
|
||||
// In practice, once listen() starts the server is running.
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_channel() -> WebhookChannel {
|
||||
WebhookChannel::new(
|
||||
8080,
|
||||
Some("/webhook".into()),
|
||||
Some("https://example.com/callback".into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn make_channel_with_secret() -> WebhookChannel {
|
||||
WebhookChannel::new(
|
||||
8080,
|
||||
None,
|
||||
Some("https://example.com/callback".into()),
|
||||
None,
|
||||
None,
|
||||
Some("mysecret".into()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_path() {
|
||||
let ch = WebhookChannel::new(8080, None, None, None, None, None);
|
||||
assert_eq!(ch.listen_path, "/webhook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_normalized() {
|
||||
let ch = WebhookChannel::new(8080, Some("hooks/incoming".into()), None, None, None, None);
|
||||
assert_eq!(ch.listen_path, "/hooks/incoming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_method_default() {
|
||||
let ch = make_channel();
|
||||
assert_eq!(ch.send_method, "POST");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_method_put() {
|
||||
let ch = WebhookChannel::new(
|
||||
8080,
|
||||
None,
|
||||
Some("https://example.com".into()),
|
||||
Some("put".into()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(ch.send_method, "PUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_payload_deserializes_all_fields() {
|
||||
let json = r#"{"sender": "zeroclaw_user", "content": "hello", "thread_id": "t1"}"#;
|
||||
let payload: IncomingWebhook = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(payload.sender, "zeroclaw_user");
|
||||
assert_eq!(payload.content, "hello");
|
||||
assert_eq!(payload.thread_id.as_deref(), Some("t1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_payload_without_thread() {
|
||||
let json = r#"{"sender": "bob", "content": "hi"}"#;
|
||||
let payload: IncomingWebhook = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(payload.sender, "bob");
|
||||
assert_eq!(payload.content, "hi");
|
||||
assert!(payload.thread_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_payload_serializes_content() {
|
||||
let payload = OutgoingWebhook {
|
||||
content: "response".into(),
|
||||
thread_id: Some("t1".into()),
|
||||
recipient: Some("zeroclaw_user".into()),
|
||||
};
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(json["content"], "response");
|
||||
assert_eq!(json["thread_id"], "t1");
|
||||
assert_eq!(json["recipient"], "zeroclaw_user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_payload_omits_none_fields() {
|
||||
let payload = OutgoingWebhook {
|
||||
content: "response".into(),
|
||||
thread_id: None,
|
||||
recipient: None,
|
||||
};
|
||||
let json = serde_json::to_value(&payload).unwrap();
|
||||
assert_eq!(json["content"], "response");
|
||||
assert!(json.get("thread_id").is_none());
|
||||
assert!(json.get("recipient").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_no_secret() {
|
||||
let ch = make_channel();
|
||||
assert!(ch.verify_signature(b"body", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_missing_header() {
|
||||
let ch = make_channel_with_secret();
|
||||
assert!(!ch.verify_signature(b"body", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_valid() {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
let ch = make_channel_with_secret();
|
||||
let body = b"test body";
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(b"mysecret").unwrap();
|
||||
mac.update(body);
|
||||
let sig = hex::encode(mac.finalize().into_bytes());
|
||||
|
||||
assert!(ch.verify_signature(body, Some(&sig)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_signature_invalid() {
|
||||
let ch = make_channel_with_secret();
|
||||
assert!(!ch.verify_signature(b"body", Some("badhex")));
|
||||
}
|
||||
}
|
||||
+350
-38
@@ -64,6 +64,17 @@ pub struct WhatsAppWebChannel {
|
||||
client: Arc<Mutex<Option<Arc<wa_rs::Client>>>>,
|
||||
/// Message sender channel
|
||||
tx: Arc<Mutex<Option<tokio::sync::mpsc::Sender<ChannelMessage>>>>,
|
||||
/// Voice transcription (STT) config
|
||||
transcription: Option<crate::config::TranscriptionConfig>,
|
||||
/// Text-to-speech config for voice replies
|
||||
tts_config: Option<crate::config::TtsConfig>,
|
||||
/// Chats awaiting a voice reply — maps chat JID to the latest substantive
|
||||
/// reply text. A background task debounces and sends the voice note after
|
||||
/// the agent finishes its turn (no new send() for 3 seconds).
|
||||
pending_voice:
|
||||
Arc<std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>>,
|
||||
/// Chats whose last incoming message was a voice note.
|
||||
voice_chats: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl WhatsAppWebChannel {
|
||||
@@ -90,9 +101,31 @@ impl WhatsAppWebChannel {
|
||||
bot_handle: Arc::new(Mutex::new(None)),
|
||||
client: Arc::new(Mutex::new(None)),
|
||||
tx: Arc::new(Mutex::new(None)),
|
||||
transcription: None,
|
||||
tts_config: None,
|
||||
pending_voice: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
voice_chats: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure voice transcription (STT) for incoming voice notes.
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
|
||||
if config.enabled {
|
||||
self.transcription = Some(config);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Configure text-to-speech for outgoing voice replies.
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
pub fn with_tts(mut self, config: crate::config::TtsConfig) -> Self {
|
||||
if config.enabled {
|
||||
self.tts_config = Some(config);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a phone number is allowed (E.164 format: +1234567890)
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn is_number_allowed(&self, phone: &str) -> bool {
|
||||
@@ -275,6 +308,134 @@ impl WhatsAppWebChannel {
|
||||
format!("{expanded_session_path}-shm"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Attempt to download and transcribe a WhatsApp voice note.
|
||||
///
|
||||
/// Returns `None` if transcription is disabled, download fails, or
|
||||
/// transcription fails (all logged as warnings).
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
async fn try_transcribe_voice_note(
|
||||
client: &wa_rs::Client,
|
||||
audio: &wa_rs_proto::whatsapp::message::AudioMessage,
|
||||
transcription_config: Option<&crate::config::TranscriptionConfig>,
|
||||
) -> Option<String> {
|
||||
let config = transcription_config?;
|
||||
|
||||
// Enforce duration limit
|
||||
if let Some(seconds) = audio.seconds {
|
||||
if u64::from(seconds) > config.max_duration_secs {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: skipping voice note ({}s exceeds {}s limit)",
|
||||
seconds,
|
||||
config.max_duration_secs
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Download the encrypted audio
|
||||
use wa_rs::download::Downloadable;
|
||||
let audio_data = match client.download(audio as &dyn Downloadable).await {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
tracing::warn!("WhatsApp Web: failed to download voice note: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine filename from mimetype for transcription API
|
||||
let file_name = match audio.mimetype.as_deref() {
|
||||
Some(m) if m.contains("opus") || m.contains("ogg") => "voice.ogg",
|
||||
Some(m) if m.contains("mp4") || m.contains("m4a") => "voice.m4a",
|
||||
Some(m) if m.contains("mpeg") || m.contains("mp3") => "voice.mp3",
|
||||
Some(m) if m.contains("webm") => "voice.webm",
|
||||
_ => "voice.ogg", // WhatsApp default
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"WhatsApp Web: transcribing voice note ({} bytes, file={})",
|
||||
audio_data.len(),
|
||||
file_name
|
||||
);
|
||||
|
||||
match super::transcription::transcribe_audio(audio_data, file_name, config).await {
|
||||
Ok(text) if text.trim().is_empty() => {
|
||||
tracing::info!("WhatsApp Web: voice transcription returned empty text, skipping");
|
||||
None
|
||||
}
|
||||
Ok(text) => {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: voice note transcribed ({} chars)",
|
||||
text.len()
|
||||
);
|
||||
Some(text)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("WhatsApp Web: voice transcription failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesize text to speech and send as a WhatsApp voice note (static version for spawned tasks).
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
async fn synthesize_voice_static(
|
||||
client: &wa_rs::Client,
|
||||
to: &wa_rs_binary::jid::Jid,
|
||||
text: &str,
|
||||
tts_config: &crate::config::TtsConfig,
|
||||
) -> Result<()> {
|
||||
let tts_manager = super::tts::TtsManager::new(tts_config)?;
|
||||
let audio_bytes = tts_manager.synthesize(text).await?;
|
||||
let audio_len = audio_bytes.len();
|
||||
tracing::info!("WhatsApp Web TTS: synthesized {} bytes of audio", audio_len);
|
||||
|
||||
if audio_bytes.is_empty() {
|
||||
anyhow::bail!("TTS returned empty audio");
|
||||
}
|
||||
|
||||
use wa_rs_core::download::MediaType;
|
||||
let upload = client
|
||||
.upload(audio_bytes, MediaType::Audio)
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to upload TTS audio: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
"WhatsApp Web TTS: uploaded audio (url_len={}, file_length={})",
|
||||
upload.url.len(),
|
||||
upload.file_length
|
||||
);
|
||||
|
||||
// Estimate duration: Opus at ~32kbps → bytes / 4000 ≈ seconds
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let estimated_seconds = std::cmp::max(1, (upload.file_length / 4000) as u32);
|
||||
|
||||
let voice_msg = wa_rs_proto::whatsapp::Message {
|
||||
audio_message: Some(Box::new(wa_rs_proto::whatsapp::message::AudioMessage {
|
||||
url: Some(upload.url),
|
||||
direct_path: Some(upload.direct_path),
|
||||
media_key: Some(upload.media_key),
|
||||
file_enc_sha256: Some(upload.file_enc_sha256),
|
||||
file_sha256: Some(upload.file_sha256),
|
||||
file_length: Some(upload.file_length),
|
||||
mimetype: Some("audio/ogg; codecs=opus".to_string()),
|
||||
ptt: Some(true),
|
||||
seconds: Some(estimated_seconds),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Box::pin(client.send_message(to.clone(), voice_msg))
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to send voice note: {e}"))?;
|
||||
tracing::info!(
|
||||
"WhatsApp Web TTS: sent voice note ({} bytes, ~{}s)",
|
||||
audio_len,
|
||||
estimated_seconds
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
@@ -303,6 +464,88 @@ impl Channel for WhatsAppWebChannel {
|
||||
}
|
||||
|
||||
let to = self.recipient_to_jid(&message.recipient)?;
|
||||
|
||||
// Voice chat mode: send text normally AND queue a voice note of the
|
||||
// final answer. Only substantive messages (not tool outputs) are queued.
|
||||
// A debounce task waits 10s after the last substantive message, then
|
||||
// sends ONE voice note. Text in → text out. Voice in → text + voice out.
|
||||
let is_voice_chat = self
|
||||
.voice_chats
|
||||
.lock()
|
||||
.map(|vs| vs.contains(&message.recipient))
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_voice_chat && self.tts_config.is_some() {
|
||||
let content = &message.content;
|
||||
// Only queue substantive natural-language replies for voice.
|
||||
// Skip tool outputs: URLs, JSON, code blocks, errors, short status.
|
||||
let is_substantive = content.len() > 40
|
||||
&& !content.starts_with("http")
|
||||
&& !content.starts_with('{')
|
||||
&& !content.starts_with('[')
|
||||
&& !content.starts_with("Error")
|
||||
&& !content.contains("```")
|
||||
&& !content.contains("tool_call")
|
||||
&& !content.contains("wttr.in");
|
||||
|
||||
if is_substantive {
|
||||
if let Ok(mut pv) = self.pending_voice.lock() {
|
||||
pv.insert(
|
||||
message.recipient.clone(),
|
||||
(content.clone(), std::time::Instant::now()),
|
||||
);
|
||||
}
|
||||
|
||||
let pending = self.pending_voice.clone();
|
||||
let voice_chats = self.voice_chats.clone();
|
||||
let client_clone = client.clone();
|
||||
let to_clone = to.clone();
|
||||
let recipient = message.recipient.clone();
|
||||
let tts_config = self.tts_config.clone().unwrap();
|
||||
tokio::spawn(async move {
|
||||
// Wait 10 seconds — long enough for the agent to finish its
|
||||
// full tool chain and send the final answer.
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
|
||||
|
||||
// Atomic check-and-remove: only one task gets the value
|
||||
let to_voice = pending.lock().ok().and_then(|mut pv| {
|
||||
if let Some((_, ts)) = pv.get(&recipient) {
|
||||
if ts.elapsed().as_secs() >= 8 {
|
||||
return pv.remove(&recipient).map(|(text, _)| text);
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
|
||||
if let Some(text) = to_voice {
|
||||
if let Ok(mut vc) = voice_chats.lock() {
|
||||
vc.remove(&recipient);
|
||||
}
|
||||
match Box::pin(WhatsAppWebChannel::synthesize_voice_static(
|
||||
&client_clone,
|
||||
&to_clone,
|
||||
&text,
|
||||
&tts_config,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: voice reply sent ({} chars)",
|
||||
text.len()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("WhatsApp Web: TTS voice reply failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Fall through to send text normally (voice chat gets BOTH)
|
||||
}
|
||||
|
||||
// Send text message
|
||||
let outgoing = wa_rs_proto::whatsapp::Message {
|
||||
conversation: Some(message.content.clone()),
|
||||
..Default::default()
|
||||
@@ -310,7 +553,7 @@ impl Channel for WhatsAppWebChannel {
|
||||
|
||||
let message_id = client.send_message(to, outgoing).await?;
|
||||
tracing::debug!(
|
||||
"WhatsApp Web: sent message to {} (id: {})",
|
||||
"WhatsApp Web: sent text to {} (id: {})",
|
||||
message.recipient,
|
||||
message_id
|
||||
);
|
||||
@@ -380,40 +623,33 @@ impl Channel for WhatsAppWebChannel {
|
||||
let logout_tx_clone = logout_tx.clone();
|
||||
let retry_count_clone = retry_count.clone();
|
||||
let session_revoked_clone = session_revoked.clone();
|
||||
let transcription_config = self.transcription.clone();
|
||||
|
||||
let transcription_config = self.transcription.clone();
|
||||
let voice_chats = self.voice_chats.clone();
|
||||
|
||||
let mut builder = Bot::builder()
|
||||
.with_backend(backend)
|
||||
.with_transport_factory(transport_factory)
|
||||
.with_http_client(http_client)
|
||||
.on_event(move |event, _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();
|
||||
let transcription_config = transcription_config.clone();
|
||||
let voice_chats = voice_chats.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
|
||||
client.get_phone_number_from_lid(&sender_jid.user).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -423,42 +659,92 @@ impl Channel for WhatsAppWebChannel {
|
||||
mapped_phone.as_deref(),
|
||||
);
|
||||
|
||||
if let Some(normalized) = sender_candidates
|
||||
let normalized = match 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
|
||||
Some(n) => n,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: message from unrecognized sender not in allowed list (candidates_count={})",
|
||||
sender_candidates.len()
|
||||
);
|
||||
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,
|
||||
})
|
||||
// Attempt voice note transcription (ptt = push-to-talk = voice note)
|
||||
let voice_text = if let Some(ref audio) = msg.audio_message {
|
||||
if audio.ptt == Some(true) {
|
||||
Self::try_transcribe_voice_note(
|
||||
&client,
|
||||
audio,
|
||||
transcription_config.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send message to channel: {}", e);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"WhatsApp Web: ignoring non-PTT audio message from {}",
|
||||
normalized
|
||||
);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: message from unrecognized sender not in allowed list (candidates_count={})",
|
||||
sender_candidates.len()
|
||||
None
|
||||
};
|
||||
|
||||
// Use transcribed voice text, or fall back to text content.
|
||||
// Track whether this chat used a voice note so we reply in kind.
|
||||
// We store the chat JID (reply_target) since that's what send() receives.
|
||||
let content = if let Some(ref vt) = voice_text {
|
||||
if let Ok(mut vs) = voice_chats.lock() {
|
||||
vs.insert(chat.clone());
|
||||
}
|
||||
format!("[Voice] {vt}")
|
||||
} else {
|
||||
if let Ok(mut vs) = voice_chats.lock() {
|
||||
vs.remove(&chat);
|
||||
}
|
||||
let text = msg.text_content().unwrap_or("");
|
||||
text.trim().to_string()
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
"WhatsApp Web message received (sender_len={}, chat_len={}, content_len={})",
|
||||
sender.len(),
|
||||
chat.len(),
|
||||
content.len()
|
||||
);
|
||||
tracing::debug!(
|
||||
"WhatsApp Web message content: {}",
|
||||
content
|
||||
);
|
||||
|
||||
if content.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,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
thread_ts: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send message to channel: {}", e);
|
||||
}
|
||||
}
|
||||
Event::Connected(_) => {
|
||||
@@ -695,6 +981,14 @@ impl WhatsAppWebChannel {
|
||||
) -> Self {
|
||||
Self { _private: () }
|
||||
}
|
||||
|
||||
pub fn with_transcription(self, _config: crate::config::TranscriptionConfig) -> Self {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_tts(self, _config: crate::config::TtsConfig) -> Self {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "whatsapp-web"))]
|
||||
@@ -936,6 +1230,24 @@ mod tests {
|
||||
assert!(WhatsAppWebChannel::should_purge_session(&flag));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn with_transcription_sets_config_when_enabled() {
|
||||
let mut tc = crate::config::TranscriptionConfig::default();
|
||||
tc.enabled = true;
|
||||
|
||||
let ch = make_channel().with_transcription(tc);
|
||||
assert!(ch.transcription.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn with_transcription_ignores_when_disabled() {
|
||||
let tc = crate::config::TranscriptionConfig::default(); // enabled = false
|
||||
let ch = make_channel().with_transcription(tc);
|
||||
assert!(ch.transcription.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn session_file_paths_includes_wal_and_shm() {
|
||||
|
||||
+21
-14
@@ -1,24 +1,31 @@
|
||||
pub mod schema;
|
||||
pub mod traits;
|
||||
pub mod workspace;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use schema::{
|
||||
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
||||
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
|
||||
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
|
||||
BuiltinHooksConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig,
|
||||
CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EdgeTtsConfig,
|
||||
ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
|
||||
GoogleTtsConfig, HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig,
|
||||
HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig, McpConfig,
|
||||
McpServerConfig, McpTransport, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
||||
NextcloudTalkConfig, NodesConfig, ObservabilityConfig, OpenAiTtsConfig, OtpConfig, OtpMethod,
|
||||
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QdrantConfig,
|
||||
QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig,
|
||||
SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig,
|
||||
SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, TelegramConfig, ToolFilterGroup, ToolFilterGroupMode,
|
||||
TranscriptionConfig, TtsConfig, TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
AgentConfig, AssemblyAiSttConfig, AuditConfig, AutonomyConfig, BackupConfig,
|
||||
BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig, ChannelsConfig,
|
||||
ClassificationRule, CloudOpsConfig, ComposioConfig, Config, ConversationalAiConfig, CostConfig,
|
||||
CronConfig, DataRetentionConfig, DeepgramSttConfig, DelegateAgentConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EdgeTtsConfig, ElevenLabsTtsConfig, EmbeddingRouteConfig, EstopConfig,
|
||||
FeishuConfig, GatewayConfig, GoogleSttConfig, GoogleTtsConfig, GoogleWorkspaceConfig,
|
||||
HardwareConfig, HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig,
|
||||
IMessageConfig, IdentityConfig, ImageProviderDalleConfig, ImageProviderFluxConfig,
|
||||
ImageProviderImagenConfig, ImageProviderStabilityConfig, KnowledgeConfig, LarkConfig,
|
||||
LinkedInConfig, LinkedInContentConfig, LinkedInImageConfig, MatrixConfig, McpConfig,
|
||||
McpServerConfig, McpTransport, MemoryConfig, Microsoft365Config, ModelRouteConfig,
|
||||
MultimodalConfig, NextcloudTalkConfig, NodeTransportConfig, NodesConfig, NotionConfig,
|
||||
ObservabilityConfig, OpenAiSttConfig, OpenAiTtsConfig, OpenVpnTunnelConfig, OtpConfig,
|
||||
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig,
|
||||
ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig,
|
||||
RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig,
|
||||
SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig,
|
||||
StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy,
|
||||
TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig,
|
||||
TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig,
|
||||
};
|
||||
|
||||
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
|
||||
+2421
-22
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,382 @@
|
||||
//! Workspace profile management for multi-client isolation.
|
||||
//!
|
||||
//! Each workspace represents an isolated client engagement with its own
|
||||
//! memory namespace, audit trail, secrets scope, and tool restrictions.
|
||||
//! Profiles are stored under `~/.zeroclaw/workspaces/<client_name>/`.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A single client workspace profile.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkspaceProfile {
|
||||
/// Human-readable workspace name (also used as directory name).
|
||||
pub name: String,
|
||||
/// Allowed domains for network access within this workspace.
|
||||
#[serde(default)]
|
||||
pub allowed_domains: Vec<String>,
|
||||
/// Credential profile name scoped to this workspace.
|
||||
#[serde(default)]
|
||||
pub credential_profile: Option<String>,
|
||||
/// Memory namespace prefix for isolation.
|
||||
#[serde(default)]
|
||||
pub memory_namespace: Option<String>,
|
||||
/// Audit namespace prefix for isolation.
|
||||
#[serde(default)]
|
||||
pub audit_namespace: Option<String>,
|
||||
/// Tool names denied in this workspace (e.g. `["shell"]` to block shell access).
|
||||
#[serde(default)]
|
||||
pub tool_restrictions: Vec<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceProfile {
|
||||
/// Effective memory namespace (falls back to workspace name).
|
||||
pub fn effective_memory_namespace(&self) -> &str {
|
||||
self.memory_namespace
|
||||
.as_deref()
|
||||
.unwrap_or(self.name.as_str())
|
||||
}
|
||||
|
||||
/// Effective audit namespace (falls back to workspace name).
|
||||
pub fn effective_audit_namespace(&self) -> &str {
|
||||
self.audit_namespace
|
||||
.as_deref()
|
||||
.unwrap_or(self.name.as_str())
|
||||
}
|
||||
|
||||
/// Returns true if the given tool name is restricted in this workspace.
|
||||
pub fn is_tool_restricted(&self, tool_name: &str) -> bool {
|
||||
self.tool_restrictions
|
||||
.iter()
|
||||
.any(|r| r.eq_ignore_ascii_case(tool_name))
|
||||
}
|
||||
|
||||
/// Returns true if the given domain is allowed for this workspace.
|
||||
/// An empty allowlist means all domains are allowed.
|
||||
pub fn is_domain_allowed(&self, domain: &str) -> bool {
|
||||
if self.allowed_domains.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let domain_lower = domain.to_ascii_lowercase();
|
||||
self.allowed_domains
|
||||
.iter()
|
||||
.any(|d| domain_lower == d.to_ascii_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
/// Manages loading and switching between client workspace profiles.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkspaceManager {
|
||||
/// Base directory containing all workspace subdirectories.
|
||||
workspaces_dir: PathBuf,
|
||||
/// Loaded workspace profiles keyed by name.
|
||||
profiles: HashMap<String, WorkspaceProfile>,
|
||||
/// Currently active workspace name.
|
||||
active: Option<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceManager {
|
||||
/// Create a new workspace manager rooted at the given directory.
|
||||
pub fn new(workspaces_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
workspaces_dir,
|
||||
profiles: HashMap::new(),
|
||||
active: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all workspace profiles from disk.
|
||||
///
|
||||
/// Each subdirectory of `workspaces_dir` that contains a `profile.toml`
|
||||
/// is treated as a workspace.
|
||||
pub async fn load_profiles(&mut self) -> Result<()> {
|
||||
self.profiles.clear();
|
||||
|
||||
let dir = &self.workspaces_dir;
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries = tokio::fs::read_dir(dir)
|
||||
.await
|
||||
.with_context(|| format!("reading workspaces directory: {}", dir.display()))?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let profile_path = path.join("profile.toml");
|
||||
if !profile_path.exists() {
|
||||
continue;
|
||||
}
|
||||
match tokio::fs::read_to_string(&profile_path).await {
|
||||
Ok(contents) => match toml::from_str::<WorkspaceProfile>(&contents) {
|
||||
Ok(profile) => {
|
||||
self.profiles.insert(profile.name.clone(), profile);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"skipping malformed workspace profile {}: {e}",
|
||||
profile_path.display()
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"skipping unreadable workspace profile {}: {e}",
|
||||
profile_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Switch to the named workspace. Returns an error if it does not exist.
|
||||
pub fn switch(&mut self, name: &str) -> Result<&WorkspaceProfile> {
|
||||
if !self.profiles.contains_key(name) {
|
||||
bail!("workspace '{}' not found", name);
|
||||
}
|
||||
self.active = Some(name.to_string());
|
||||
Ok(&self.profiles[name])
|
||||
}
|
||||
|
||||
/// Get the currently active workspace profile, if any.
|
||||
pub fn active_profile(&self) -> Option<&WorkspaceProfile> {
|
||||
self.active
|
||||
.as_deref()
|
||||
.and_then(|name| self.profiles.get(name))
|
||||
}
|
||||
|
||||
/// Get the active workspace name.
|
||||
pub fn active_name(&self) -> Option<&str> {
|
||||
self.active.as_deref()
|
||||
}
|
||||
|
||||
/// List all loaded workspace names.
|
||||
pub fn list(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
|
||||
names.sort_unstable();
|
||||
names
|
||||
}
|
||||
|
||||
/// Get a workspace profile by name.
|
||||
pub fn get(&self, name: &str) -> Option<&WorkspaceProfile> {
|
||||
self.profiles.get(name)
|
||||
}
|
||||
|
||||
/// Create a new workspace on disk and register it.
|
||||
pub async fn create(&mut self, name: &str) -> Result<&WorkspaceProfile> {
|
||||
if name.is_empty() {
|
||||
bail!("workspace name must not be empty");
|
||||
}
|
||||
// Validate name: alphanumeric, hyphens, underscores only
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
bail!(
|
||||
"workspace name must contain only alphanumeric characters, hyphens, or underscores"
|
||||
);
|
||||
}
|
||||
if self.profiles.contains_key(name) {
|
||||
bail!("workspace '{}' already exists", name);
|
||||
}
|
||||
|
||||
let ws_dir = self.workspaces_dir.join(name);
|
||||
tokio::fs::create_dir_all(&ws_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating workspace directory: {}", ws_dir.display()))?;
|
||||
|
||||
let profile = WorkspaceProfile {
|
||||
name: name.to_string(),
|
||||
allowed_domains: Vec::new(),
|
||||
credential_profile: None,
|
||||
memory_namespace: Some(name.to_string()),
|
||||
audit_namespace: Some(name.to_string()),
|
||||
tool_restrictions: Vec::new(),
|
||||
};
|
||||
|
||||
let toml_str = toml::to_string_pretty(&profile).context("serializing workspace profile")?;
|
||||
let profile_path = ws_dir.join("profile.toml");
|
||||
tokio::fs::write(&profile_path, toml_str)
|
||||
.await
|
||||
.with_context(|| format!("writing workspace profile: {}", profile_path.display()))?;
|
||||
|
||||
self.profiles.insert(name.to_string(), profile);
|
||||
Ok(&self.profiles[name])
|
||||
}
|
||||
|
||||
/// Export a workspace profile as a sanitized TOML string (no secrets).
|
||||
pub fn export(&self, name: &str) -> Result<String> {
|
||||
let profile = self
|
||||
.profiles
|
||||
.get(name)
|
||||
.with_context(|| format!("workspace '{}' not found", name))?;
|
||||
|
||||
// Create an export-safe copy with credential_profile redacted
|
||||
let export = WorkspaceProfile {
|
||||
credential_profile: profile
|
||||
.credential_profile
|
||||
.as_ref()
|
||||
.map(|_| "***".to_string()),
|
||||
..profile.clone()
|
||||
};
|
||||
|
||||
toml::to_string_pretty(&export).context("serializing workspace profile for export")
|
||||
}
|
||||
|
||||
/// Directory for a specific workspace.
|
||||
pub fn workspace_dir(&self, name: &str) -> PathBuf {
|
||||
self.workspaces_dir.join(name)
|
||||
}
|
||||
|
||||
/// Base workspaces directory.
|
||||
pub fn workspaces_dir(&self) -> &Path {
|
||||
&self.workspaces_dir
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn sample_profile(name: &str) -> WorkspaceProfile {
|
||||
WorkspaceProfile {
|
||||
name: name.to_string(),
|
||||
allowed_domains: vec!["example.com".to_string()],
|
||||
credential_profile: Some("test-creds".to_string()),
|
||||
memory_namespace: Some(format!("{name}_mem")),
|
||||
audit_namespace: Some(format!("{name}_audit")),
|
||||
tool_restrictions: vec!["shell".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_profile_tool_restriction_check() {
|
||||
let profile = sample_profile("client_a");
|
||||
assert!(profile.is_tool_restricted("shell"));
|
||||
assert!(profile.is_tool_restricted("Shell"));
|
||||
assert!(!profile.is_tool_restricted("file_read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_profile_domain_allowlist_empty_allows_all() {
|
||||
let mut profile = sample_profile("client_a");
|
||||
profile.allowed_domains.clear();
|
||||
assert!(profile.is_domain_allowed("anything.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_profile_domain_allowlist_enforced() {
|
||||
let profile = sample_profile("client_a");
|
||||
assert!(profile.is_domain_allowed("example.com"));
|
||||
assert!(!profile.is_domain_allowed("other.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_profile_effective_namespaces() {
|
||||
let profile = sample_profile("client_a");
|
||||
assert_eq!(profile.effective_memory_namespace(), "client_a_mem");
|
||||
assert_eq!(profile.effective_audit_namespace(), "client_a_audit");
|
||||
|
||||
let fallback = WorkspaceProfile {
|
||||
name: "test_ws".to_string(),
|
||||
memory_namespace: None,
|
||||
audit_namespace: None,
|
||||
..sample_profile("test_ws")
|
||||
};
|
||||
assert_eq!(fallback.effective_memory_namespace(), "test_ws");
|
||||
assert_eq!(fallback.effective_audit_namespace(), "test_ws");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_manager_create_and_list() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
|
||||
mgr.create("client_alpha").await.unwrap();
|
||||
mgr.create("client_beta").await.unwrap();
|
||||
|
||||
let names = mgr.list();
|
||||
assert_eq!(names, vec!["client_alpha", "client_beta"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_manager_create_rejects_duplicate() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
|
||||
mgr.create("client_a").await.unwrap();
|
||||
let result = mgr.create("client_a").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_manager_create_rejects_invalid_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
|
||||
assert!(mgr.create("").await.is_err());
|
||||
assert!(mgr.create("bad name").await.is_err());
|
||||
assert!(mgr.create("../escape").await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_manager_switch_and_active() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
|
||||
mgr.create("ws_one").await.unwrap();
|
||||
assert!(mgr.active_profile().is_none());
|
||||
|
||||
mgr.switch("ws_one").unwrap();
|
||||
assert_eq!(mgr.active_name(), Some("ws_one"));
|
||||
assert!(mgr.active_profile().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_manager_switch_nonexistent_fails() {
|
||||
let mgr = WorkspaceManager::new(PathBuf::from("/tmp/nonexistent"));
|
||||
let mut mgr = mgr;
|
||||
assert!(mgr.switch("no_such_ws").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_manager_load_profiles_from_disk() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
|
||||
// Create a workspace via the manager
|
||||
mgr.create("loaded_ws").await.unwrap();
|
||||
|
||||
// Create a fresh manager and load from disk
|
||||
let mut mgr2 = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
mgr2.load_profiles().await.unwrap();
|
||||
|
||||
assert_eq!(mgr2.list(), vec!["loaded_ws"]);
|
||||
let profile = mgr2.get("loaded_ws").unwrap();
|
||||
assert_eq!(profile.name, "loaded_ws");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_manager_export_redacts_credentials() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
|
||||
mgr.create("export_test").await.unwrap();
|
||||
|
||||
// Manually set a credential profile
|
||||
if let Some(profile) = mgr.profiles.get_mut("export_test") {
|
||||
profile.credential_profile = Some("secret-cred-id".to_string());
|
||||
}
|
||||
|
||||
let exported = mgr.export("export_test").unwrap();
|
||||
assert!(exported.contains("***"));
|
||||
assert!(!exported.contains("secret-cred-id"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user