Merge remote-tracking branch 'origin/main' into pr2093-mainmerge
This commit is contained in:
commit
f7de9cda3a
19
.cargo/armv6l-unknown-linux-musleabihf.json
Normal file
19
.cargo/armv6l-unknown-linux-musleabihf.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"arch": "arm",
|
||||
"crt-static-defaults": true,
|
||||
"data-layout": "e-m:e-p:32:32-Fi8-i64:64-v128:64:128-a:0:32-n32-S64",
|
||||
"emit-debug-gdb-scripts": false,
|
||||
"env": "musl",
|
||||
"executables": true,
|
||||
"is-builtin": false,
|
||||
"linker": "arm-linux-gnueabihf-gcc",
|
||||
"linker-flavor": "gcc",
|
||||
"llvm-target": "armv6-unknown-linux-musleabihf",
|
||||
"max-atomic-width": 32,
|
||||
"os": "linux",
|
||||
"panic-strategy": "unwind",
|
||||
"relocation-model": "static",
|
||||
"target-endian": "little",
|
||||
"target-pointer-width": "32",
|
||||
"vendor": "unknown"
|
||||
}
|
||||
@ -4,15 +4,14 @@ rustflags = ["-C", "link-arg=-static"]
|
||||
[target.aarch64-unknown-linux-musl]
|
||||
rustflags = ["-C", "link-arg=-static"]
|
||||
|
||||
# Android targets (NDK toolchain)
|
||||
# ARMv6 musl (Raspberry Pi Zero W)
|
||||
[target.armv6l-unknown-linux-musleabihf]
|
||||
rustflags = ["-C", "link-arg=-static"]
|
||||
|
||||
# Android targets (Termux-native defaults).
|
||||
# CI/NDK cross builds can override these via CARGO_TARGET_*_LINKER.
|
||||
[target.armv7-linux-androideabi]
|
||||
linker = "armv7a-linux-androideabi21-clang"
|
||||
linker = "clang"
|
||||
|
||||
[target.aarch64-linux-android]
|
||||
linker = "aarch64-linux-android21-clang"
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = ["-C", "link-arg=-Wl,--stack,8388608"]
|
||||
linker = "clang"
|
||||
|
||||
7
.github/security/deny-ignore-governance.json
vendored
7
.github/security/deny-ignore-governance.json
vendored
@ -21,6 +21,13 @@
|
||||
"reason": "Transitive via matrix-sdk indexeddb dependency chain in current matrix release line; track removal when upstream drops derivative.",
|
||||
"ticket": "RMN-21",
|
||||
"expires_on": "2026-12-31"
|
||||
},
|
||||
{
|
||||
"id": "RUSTSEC-2024-0436",
|
||||
"owner": "repo-maintainers",
|
||||
"reason": "Transitive via wasmtime dependency stack; tracked until upstream removes or replaces paste.",
|
||||
"ticket": "RMN-21",
|
||||
"expires_on": "2026-12-31"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
1
.github/workflows/README.md
vendored
1
.github/workflows/README.md
vendored
@ -19,7 +19,6 @@ Workflow behavior documentation in this directory:
|
||||
|
||||
Current workflow helper scripts:
|
||||
|
||||
- `.github/workflows/scripts/ci_workflow_owner_approval.js`
|
||||
- `.github/workflows/scripts/ci_license_file_owner_guard.js`
|
||||
- `.github/workflows/scripts/lint_feedback.js`
|
||||
- `.github/workflows/scripts/pr_auto_response_contributor_tier.js`
|
||||
|
||||
7
.github/workflows/ci-change-audit.yml
vendored
7
.github/workflows/ci-change-audit.yml
vendored
@ -50,7 +50,7 @@ env:
|
||||
jobs:
|
||||
audit:
|
||||
name: CI Change Audit
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
@ -58,6 +58,11 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Resolve base/head commits
|
||||
id: refs
|
||||
shell: bash
|
||||
|
||||
144
.github/workflows/ci-queue-hygiene.yml
vendored
Normal file
144
.github/workflows/ci-queue-hygiene.yml
vendored
Normal file
@ -0,0 +1,144 @@
|
||||
name: CI Queue Hygiene
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "*/15 * * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
apply:
|
||||
description: "Cancel selected queued runs (false = dry-run report only)"
|
||||
required: true
|
||||
default: true
|
||||
type: boolean
|
||||
status:
|
||||
description: "Queued-run status scope"
|
||||
required: true
|
||||
default: queued
|
||||
type: choice
|
||||
options:
|
||||
- queued
|
||||
- in_progress
|
||||
- requested
|
||||
- waiting
|
||||
max_cancel:
|
||||
description: "Maximum runs to cancel in one execution"
|
||||
required: true
|
||||
default: "120"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: ci-queue-hygiene
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GIT_CONFIG_COUNT: "1"
|
||||
GIT_CONFIG_KEY_0: core.hooksPath
|
||||
GIT_CONFIG_VALUE_0: /dev/null
|
||||
|
||||
jobs:
|
||||
hygiene:
|
||||
name: Queue Hygiene
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Run queue hygiene policy
|
||||
id: hygiene
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts
|
||||
|
||||
status_scope="queued"
|
||||
max_cancel="120"
|
||||
apply_mode="true"
|
||||
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
|
||||
status_scope="${{ github.event.inputs.status || 'queued' }}"
|
||||
max_cancel="${{ github.event.inputs.max_cancel || '120' }}"
|
||||
apply_mode="${{ github.event.inputs.apply || 'true' }}"
|
||||
fi
|
||||
|
||||
cmd=(python3 scripts/ci/queue_hygiene.py
|
||||
--repo "${{ github.repository }}"
|
||||
--status "${status_scope}"
|
||||
--max-cancel "${max_cancel}"
|
||||
--dedupe-workflow "PR Intake Checks"
|
||||
--dedupe-workflow "PR Labeler"
|
||||
--dedupe-workflow "PR Auto Responder"
|
||||
--dedupe-workflow "Workflow Sanity"
|
||||
--dedupe-workflow "PR Label Policy Check"
|
||||
--output-json artifacts/queue-hygiene-report.json
|
||||
--verbose)
|
||||
|
||||
if [ "${apply_mode}" = "true" ]; then
|
||||
cmd+=(--apply)
|
||||
fi
|
||||
|
||||
"${cmd[@]}"
|
||||
|
||||
{
|
||||
echo "status_scope=${status_scope}"
|
||||
echo "max_cancel=${max_cancel}"
|
||||
echo "apply_mode=${apply_mode}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish queue hygiene summary
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f artifacts/queue-hygiene-report.json ]; then
|
||||
echo "Queue hygiene report not found." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
report_path = Path("artifacts/queue-hygiene-report.json")
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
counts = report.get("counts", {})
|
||||
results = report.get("results", {})
|
||||
reasons = report.get("reason_counts", {})
|
||||
|
||||
lines = [
|
||||
"### Queue Hygiene Report",
|
||||
f"- Mode: `{report.get('mode', 'unknown')}`",
|
||||
f"- Status scope: `{report.get('status_scope', 'queued')}`",
|
||||
f"- Runs in scope: `{counts.get('runs_in_scope', 0)}`",
|
||||
f"- Candidate runs before cap: `{counts.get('candidate_runs_before_cap', 0)}`",
|
||||
f"- Candidate runs after cap: `{counts.get('candidate_runs_after_cap', 0)}`",
|
||||
f"- Skipped by cap: `{counts.get('skipped_by_cap', 0)}`",
|
||||
f"- Canceled: `{results.get('canceled', 0)}`",
|
||||
f"- Cancel skipped (already terminal/conflict): `{results.get('skipped', 0)}`",
|
||||
f"- Cancel failed: `{results.get('failed', 0)}`",
|
||||
]
|
||||
if reasons:
|
||||
lines.append("")
|
||||
lines.append("Reason counts:")
|
||||
for reason, value in sorted(reasons.items()):
|
||||
lines.append(f"- `{reason}`: `{value}`")
|
||||
|
||||
with Path("/tmp/queue-hygiene-summary.md").open("w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
PY
|
||||
|
||||
cat /tmp/queue-hygiene-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload queue hygiene report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: queue-hygiene-report
|
||||
path: artifacts/queue-hygiene-report.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
50
.github/workflows/ci-run.yml
vendored
50
.github/workflows/ci-run.yml
vendored
@ -24,7 +24,7 @@ env:
|
||||
jobs:
|
||||
changes:
|
||||
name: Detect Change Scope
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
docs_only: ${{ steps.scope.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.scope.outputs.docs_changed }}
|
||||
@ -118,9 +118,11 @@ jobs:
|
||||
if [ -f artifacts/flake-probe.json ]; then
|
||||
status=$(python3 -c "import json; print(json.load(open('artifacts/flake-probe.json'))['status'])")
|
||||
flake=$(python3 -c "import json; print(json.load(open('artifacts/flake-probe.json'))['flake_suspected'])")
|
||||
echo "### Test Flake Probe" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Status: \`${status}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- Flake suspected: \`${flake}\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo "### Test Flake Probe"
|
||||
echo "- Status: \`${status}\`"
|
||||
echo "- Flake suspected: \`${flake}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
- name: Upload flake probe artifact
|
||||
if: always()
|
||||
@ -156,7 +158,7 @@ jobs:
|
||||
name: Docs-Only Fast Path
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.docs_only == 'true'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Skip heavy jobs for docs-only change
|
||||
run: echo "Docs-only change detected. Rust lint/test/build skipped."
|
||||
@ -165,7 +167,7 @@ jobs:
|
||||
name: Non-Rust Fast Path
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.rust_changed != 'true'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Skip Rust jobs for non-Rust change scope
|
||||
run: echo "No Rust-impacting files changed. Rust lint/test/build skipped."
|
||||
@ -174,7 +176,7 @@ jobs:
|
||||
name: Docs Quality
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.docs_changed == 'true'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
@ -229,7 +231,7 @@ jobs:
|
||||
name: Lint Feedback
|
||||
if: github.event_name == 'pull_request'
|
||||
needs: [changes, lint, docs-quality]
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
@ -251,30 +253,11 @@ jobs:
|
||||
const script = require('./.github/workflows/scripts/lint_feedback.js');
|
||||
await script({github, context, core});
|
||||
|
||||
workflow-owner-approval:
|
||||
name: CI/CD Owner Approval (@chumyin)
|
||||
needs: [changes]
|
||||
if: github.event_name == 'pull_request' && needs.changes.outputs.ci_cd_changed == 'true'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Require @chumyin approval for CI/CD related changes
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const script = require('./.github/workflows/scripts/ci_workflow_owner_approval.js');
|
||||
await script({ github, context, core });
|
||||
|
||||
license-file-owner-guard:
|
||||
name: License File Owner Guard
|
||||
needs: [changes]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@ -291,8 +274,8 @@ jobs:
|
||||
ci-required:
|
||||
name: CI Required Gate
|
||||
if: always()
|
||||
needs: [changes, lint, test, build, docs-only, non-rust, docs-quality, lint-feedback, workflow-owner-approval, license-file-owner-guard]
|
||||
runs-on: [self-hosted, aws-india]
|
||||
needs: [changes, lint, test, build, docs-only, non-rust, docs-quality, lint-feedback, license-file-owner-guard]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Enforce required status
|
||||
shell: bash
|
||||
@ -302,18 +285,12 @@ jobs:
|
||||
event_name="${{ github.event_name }}"
|
||||
rust_changed="${{ needs.changes.outputs.rust_changed }}"
|
||||
docs_changed="${{ needs.changes.outputs.docs_changed }}"
|
||||
ci_cd_changed="${{ needs.changes.outputs.ci_cd_changed }}"
|
||||
docs_result="${{ needs.docs-quality.result }}"
|
||||
workflow_owner_result="${{ needs.workflow-owner-approval.result }}"
|
||||
license_owner_result="${{ needs.license-file-owner-guard.result }}"
|
||||
|
||||
# --- Helper: enforce PR governance gates ---
|
||||
check_pr_governance() {
|
||||
if [ "$event_name" != "pull_request" ]; then return 0; fi
|
||||
if [ "$ci_cd_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then
|
||||
echo "CI/CD related files changed but required @chumyin approval gate did not pass."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$license_owner_result" != "success" ]; then
|
||||
echo "License file owner guard did not pass."
|
||||
exit 1
|
||||
@ -352,7 +329,6 @@ jobs:
|
||||
echo "test=${test_result}"
|
||||
echo "build=${build_result}"
|
||||
echo "docs=${docs_result}"
|
||||
echo "workflow_owner_approval=${workflow_owner_result}"
|
||||
echo "license_file_owner_guard=${license_owner_result}"
|
||||
|
||||
check_pr_governance
|
||||
|
||||
18
.github/workflows/main-branch-flow.md
vendored
18
.github/workflows/main-branch-flow.md
vendored
@ -16,7 +16,7 @@ Use this with:
|
||||
| PR activity (`pull_request`) | `ci-run.yml`, `sec-audit.yml`, plus path-scoped workflows |
|
||||
| Push to `dev`/`main` | `ci-run.yml`, `sec-audit.yml`, plus path-scoped workflows |
|
||||
| Tag push (`v*`) | `pub-release.yml` publish mode, `pub-docker-img.yml` publish job |
|
||||
| Scheduled/manual | `pub-release.yml` verification mode, `sec-codeql.yml`, `feature-matrix.yml`, `test-fuzz.yml`, `pr-check-stale.yml`, `pr-check-status.yml`, `sync-contributors.yml`, `test-benchmarks.yml`, `test-e2e.yml` |
|
||||
| Scheduled/manual | `pub-release.yml` verification mode, `sec-codeql.yml`, `feature-matrix.yml`, `test-fuzz.yml`, `pr-check-stale.yml`, `pr-check-status.yml`, `ci-queue-hygiene.yml`, `sync-contributors.yml`, `test-benchmarks.yml`, `test-e2e.yml` |
|
||||
|
||||
## Runtime and Docker Matrix
|
||||
|
||||
@ -76,12 +76,11 @@ Notes:
|
||||
- `test`
|
||||
- `flake-probe` (single-retry telemetry; optional block via `CI_BLOCK_ON_FLAKE_SUSPECTED`)
|
||||
- `docs-quality`
|
||||
7. If `.github/workflows/**` changed, `workflow-owner-approval` must pass.
|
||||
8. If root license files (`LICENSE-APACHE`, `LICENSE-MIT`) changed, `license-file-owner-guard` allows only PR author `willsarg`.
|
||||
9. `lint-feedback` posts actionable comment if lint/docs gates fail.
|
||||
10. `CI Required Gate` aggregates results to final pass/fail.
|
||||
11. Maintainer merges PR once checks and review policy are satisfied.
|
||||
12. Merge emits a `push` event on `dev` (see scenario 4).
|
||||
7. If root license files (`LICENSE-APACHE`, `LICENSE-MIT`) changed, `license-file-owner-guard` allows only PR author `willsarg`.
|
||||
8. `lint-feedback` posts actionable comment if lint/docs gates fail.
|
||||
9. `CI Required Gate` aggregates results to final pass/fail.
|
||||
10. Maintainer merges PR once checks and review policy are satisfied.
|
||||
11. Merge emits a `push` event on `dev` (see scenario 4).
|
||||
|
||||
### 2) PR from fork -> `dev`
|
||||
|
||||
@ -110,11 +109,9 @@ Notes:
|
||||
- `changes` computes `docs_only`, `docs_changed`, `rust_changed`, `workflow_changed`.
|
||||
- `build` runs for Rust-impacting changes.
|
||||
- `lint`/`lint-strict-delta`/`test`/`docs-quality` run on PR when `ci:full` label exists.
|
||||
- `workflow-owner-approval` runs when `.github/workflows/**` changed.
|
||||
- `CI Required Gate` emits final pass/fail for the PR head.
|
||||
8. Fork PR merge blockers to check first when diagnosing stalls:
|
||||
- run approval pending for fork workflows.
|
||||
- `workflow-owner-approval` failing on workflow-file changes.
|
||||
- `license-file-owner-guard` failing when root license files are modified by non-owner PR author.
|
||||
- `CI Required Gate` failure caused by upstream jobs.
|
||||
- repeated `pull_request_target` reruns from label churn causing noisy signals.
|
||||
@ -202,7 +199,7 @@ Canary policy lane:
|
||||
|
||||
## Merge/Policy Notes
|
||||
|
||||
1. Workflow-file changes (`.github/workflows/**`) activate owner-approval gate in `ci-run.yml`.
|
||||
1. Workflow-file changes (`.github/workflows/**`) are validated through `pr-intake-checks.yml`, `ci-change-audit.yml`, and `CI Required Gate` without a dedicated owner-approval gate.
|
||||
2. PR lint/test strictness is intentionally controlled by `ci:full` label.
|
||||
3. `pr-intake-checks.yml` now blocks PRs missing a Linear issue key (`RMN-*`, `CDV-*`, `COM-*`) to keep execution mapped to Linear.
|
||||
4. `sec-audit.yml` runs on PR/push/merge queue (`merge_group`), plus scheduled weekly.
|
||||
@ -214,6 +211,7 @@ Canary policy lane:
|
||||
10. Workflow-specific JavaScript helpers are organized under `.github/workflows/scripts/`.
|
||||
11. `ci-run.yml` includes cache partitioning (`prefix-key`) across lint/test/build/flake-probe lanes to reduce cache contention.
|
||||
12. `ci-rollback.yml` provides a guarded rollback planning lane (scheduled dry-run + manual execute controls) with audit artifacts.
|
||||
13. `ci-queue-hygiene.yml` periodically deduplicates superseded queued runs for lightweight PR automation workflows to reduce queue pressure.
|
||||
|
||||
## Mermaid Diagrams
|
||||
|
||||
|
||||
6
.github/workflows/pr-auto-response.yml
vendored
6
.github/workflows/pr-auto-response.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
||||
(github.event.action == 'opened' || github.event.action == 'reopened' || github.event.action == 'labeled' || github.event.action == 'unlabeled')) ||
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
(github.event.action == 'labeled' || github.event.action == 'unlabeled'))
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
@ -45,7 +45,7 @@ jobs:
|
||||
await script({ github, context, core });
|
||||
first-interaction:
|
||||
if: github.event.action == 'opened'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
@ -76,7 +76,7 @@ jobs:
|
||||
|
||||
labeled-routes:
|
||||
if: github.event.action == 'labeled'
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
3
.github/workflows/pr-check-stale.yml
vendored
3
.github/workflows/pr-check-stale.yml
vendored
@ -17,7 +17,8 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
|
||||
3
.github/workflows/pr-check-status.yml
vendored
3
.github/workflows/pr-check-status.yml
vendored
@ -18,7 +18,8 @@ env:
|
||||
|
||||
jobs:
|
||||
nudge-stale-prs:
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
2
.github/workflows/pr-intake-checks.yml
vendored
2
.github/workflows/pr-intake-checks.yml
vendored
@ -23,7 +23,7 @@ env:
|
||||
jobs:
|
||||
intake:
|
||||
name: Intake Checks
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
3
.github/workflows/pr-label-policy-check.yml
vendored
3
.github/workflows/pr-label-policy-check.yml
vendored
@ -7,6 +7,7 @@ on:
|
||||
- ".github/workflows/pr-labeler.yml"
|
||||
- ".github/workflows/pr-auto-response.yml"
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- ".github/label-policy.json"
|
||||
- ".github/workflows/pr-labeler.yml"
|
||||
@ -27,7 +28,7 @@ env:
|
||||
|
||||
jobs:
|
||||
contributor-tier-consistency:
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
2
.github/workflows/pr-labeler.yml
vendored
2
.github/workflows/pr-labeler.yml
vendored
@ -32,7 +32,7 @@ env:
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
// Extracted from ci-run.yml step: Require @chumyin approval for CI/CD related changes
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = context.payload.pull_request?.number;
|
||||
if (!prNumber) {
|
||||
core.setFailed("Missing pull_request context.");
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredApprover = "chumyin";
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const ciCdFiles = files
|
||||
.map((file) => file.filename)
|
||||
.filter((name) =>
|
||||
name.startsWith(".github/workflows/") ||
|
||||
name.startsWith(".github/codeql/") ||
|
||||
name.startsWith(".github/connectivity/") ||
|
||||
name.startsWith(".github/release/") ||
|
||||
name.startsWith(".github/security/") ||
|
||||
name.startsWith("scripts/ci/") ||
|
||||
name === ".github/actionlint.yaml" ||
|
||||
name === ".github/dependabot.yml" ||
|
||||
name === "docs/ci-map.md" ||
|
||||
name === "docs/actions-source-policy.md" ||
|
||||
name === "docs/operations/self-hosted-runner-remediation.md",
|
||||
);
|
||||
|
||||
if (ciCdFiles.length === 0) {
|
||||
core.info("No CI/CD related files changed in this PR.");
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`CI/CD related files changed:\n- ${ciCdFiles.join("\n- ")}`);
|
||||
core.info(`Required approver: @${requiredApprover}`);
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const latestReviewByUser = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login;
|
||||
if (!login) continue;
|
||||
latestReviewByUser.set(login.toLowerCase(), review.state);
|
||||
}
|
||||
|
||||
const approvedUsers = [...latestReviewByUser.entries()]
|
||||
.filter(([, state]) => state === "APPROVED")
|
||||
.map(([login]) => login);
|
||||
|
||||
if (approvedUsers.length === 0) {
|
||||
core.setFailed("CI/CD related files changed but no approving review is present.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!approvedUsers.includes(requiredApprover)) {
|
||||
core.setFailed(
|
||||
`CI/CD related files changed. Approvals found (${approvedUsers.join(", ")}), but @${requiredApprover} approval is required.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Required CI/CD approval present: @${requiredApprover}`);
|
||||
|
||||
};
|
||||
@ -134,13 +134,11 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
const isBlocking = blockingFindings.length > 0;
|
||||
|
||||
const ownerApprovalNote = workflowFilesChanged.length > 0
|
||||
const workflowChangeNote = workflowFilesChanged.length > 0
|
||||
? [
|
||||
"",
|
||||
"Workflow files changed in this PR:",
|
||||
...workflowFilesChanged.map((name) => `- \`${name}\``),
|
||||
"",
|
||||
"Reminder: workflow changes require owner approval via `CI Required Gate`.",
|
||||
].join("\n")
|
||||
: "";
|
||||
|
||||
@ -174,7 +172,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
"",
|
||||
"Detected advisory line issues (sample):",
|
||||
...(advisoryDetails.length > 0 ? advisoryDetails : ["- none"]),
|
||||
ownerApprovalNote,
|
||||
workflowChangeNote,
|
||||
].join("\n");
|
||||
|
||||
if (existing) {
|
||||
|
||||
3
.github/workflows/sync-contributors.yml
vendored
3
.github/workflows/sync-contributors.yml
vendored
@ -17,7 +17,8 @@ permissions:
|
||||
jobs:
|
||||
update-notice:
|
||||
name: Update NOTICE with new contributors
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
5
.github/workflows/workflow-sanity.yml
vendored
5
.github/workflows/workflow-sanity.yml
vendored
@ -7,6 +7,7 @@ on:
|
||||
- ".github/*.yml"
|
||||
- ".github/*.yaml"
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
- ".github/*.yml"
|
||||
@ -27,7 +28,7 @@ env:
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Normalize git global hooks config
|
||||
@ -66,7 +67,7 @@ jobs:
|
||||
PY
|
||||
|
||||
actionlint:
|
||||
runs-on: [self-hosted, aws-india]
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Normalize git global hooks config
|
||||
|
||||
28
Cargo.lock
generated
28
Cargo.lock
generated
@ -7315,6 +7315,12 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.19.0"
|
||||
@ -9298,14 +9304,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "0.6.6"
|
||||
version = "8.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261"
|
||||
checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -9320,6 +9328,18 @@ version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
@ -62,7 +62,7 @@ urlencoding = "2.1"
|
||||
nanohtml2text = "0.2"
|
||||
|
||||
# Zip archive extraction
|
||||
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
||||
zip = { version = "8.1", default-features = false, features = ["deflate"] }
|
||||
|
||||
# XML parsing (DOCX text extraction)
|
||||
quick-xml = "0.37"
|
||||
@ -205,6 +205,8 @@ landlock = { version = "0.4", optional = true }
|
||||
libc = "0.2"
|
||||
|
||||
[features]
|
||||
# Default enables wasm-tools where platform runtime dependencies are available.
|
||||
# Unsupported targets (for example Android/Termux) use a stub implementation.
|
||||
default = ["wasm-tools"]
|
||||
hardware = ["nusb", "tokio-serial"]
|
||||
channel-matrix = ["dep:matrix-sdk"]
|
||||
@ -228,12 +230,14 @@ probe = ["dep:probe-rs"]
|
||||
# rag-pdf = PDF ingestion for datasheet RAG
|
||||
rag-pdf = ["dep:pdf-extract"]
|
||||
# wasm-tools = WASM plugin engine for dynamically-loaded tool packages (WASI stdio protocol)
|
||||
# Runtime implementation is active on Linux/macOS/Windows; unsupported targets use stubs.
|
||||
wasm-tools = ["dep:wasmtime", "dep:wasmtime-wasi"]
|
||||
# whatsapp-web = Native WhatsApp Web client with custom rusqlite storage backend
|
||||
whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-proto", "dep:wa-rs-ureq-http", "dep:wa-rs-tokio-transport", "dep:serde-big-array", "dep:prost", "dep:qrcode"]
|
||||
# Optional provider feature flags used by cfg(feature = "...") guards.
|
||||
# Keep disabled by default to preserve current runtime behavior.
|
||||
firecrawl = []
|
||||
web-fetch-html2md = []
|
||||
web-fetch-plaintext = []
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
|
||||
@ -84,7 +84,7 @@ allow_public_bind = false
|
||||
EOF
|
||||
|
||||
# ── Stage 2: Development Runtime (Debian) ────────────────────
|
||||
FROM debian:trixie-slim@sha256:f6e2cfac5cf956ea044b4bd75e6397b4372ad88fe00908045e9a0d21712ae3ba AS dev
|
||||
FROM debian:trixie-slim@sha256:1d3c811171a08a5adaa4a163fbafd96b61b87aa871bbc7aa15431ac275d3d430 AS dev
|
||||
|
||||
# Install essential runtime dependencies only (use docker-compose.override.yml for dev tools)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
39
README.md
39
README.md
@ -81,6 +81,45 @@ Use this board for important notices (breaking changes, security advisories, mai
|
||||
- **Fully swappable:** core systems are traits (providers, channels, tools, memory, tunnels).
|
||||
- **No lock-in:** OpenAI-compatible provider support + pluggable custom endpoints.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Homebrew (macOS/Linuxbrew)
|
||||
|
||||
```bash
|
||||
brew install zeroclaw
|
||||
```
|
||||
|
||||
### Option 2: Clone + Bootstrap
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zeroclaw-labs/zeroclaw.git
|
||||
cd zeroclaw
|
||||
./bootstrap.sh
|
||||
```
|
||||
|
||||
> **Note:** Source builds require ~2GB RAM and ~6GB disk. For resource-constrained systems, use `./bootstrap.sh --prefer-prebuilt` to download a pre-built binary instead.
|
||||
|
||||
### Option 3: Cargo Install
|
||||
|
||||
```bash
|
||||
cargo install zeroclaw
|
||||
```
|
||||
|
||||
### First Run
|
||||
|
||||
```bash
|
||||
# Start the gateway daemon
|
||||
zeroclaw gateway start
|
||||
|
||||
# Open the web UI
|
||||
zeroclaw dashboard
|
||||
|
||||
# Or chat directly
|
||||
zeroclaw chat "Hello!"
|
||||
```
|
||||
|
||||
For detailed setup options, see [docs/one-click-bootstrap.md](docs/one-click-bootstrap.md).
|
||||
|
||||
## Benchmark Snapshot (ZeroClaw vs OpenClaw, Reproducible)
|
||||
|
||||
Local machine quick benchmark (macOS arm64, Feb 2026) normalized for 0.8GHz edge hardware.
|
||||
|
||||
@ -41,6 +41,7 @@ impl BenchProvider {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}]),
|
||||
}
|
||||
}
|
||||
@ -57,12 +58,14 @@ impl BenchProvider {
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
},
|
||||
ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
},
|
||||
]),
|
||||
}
|
||||
@ -94,6 +97,7 @@ impl Provider for BenchProvider {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
@ -161,6 +165,7 @@ Let me know if you need more."#
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
let multi_tool = ChatResponse {
|
||||
@ -179,6 +184,7 @@ Let me know if you need more."#
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
c.bench_function("xml_parse_single_tool_call", |b| {
|
||||
@ -213,6 +219,7 @@ fn bench_native_parsing(c: &mut Criterion) {
|
||||
],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
c.bench_function("native_parse_tool_calls", |b| {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
This file is the canonical table of contents for the documentation system.
|
||||
|
||||
Last refreshed: **February 25, 2026**.
|
||||
Last refreshed: **February 28, 2026**.
|
||||
|
||||
## Language Entry
|
||||
|
||||
@ -77,6 +77,7 @@ Last refreshed: **February 25, 2026**.
|
||||
### 5) Hardware & Peripherals
|
||||
|
||||
- [hardware/README.md](hardware/README.md)
|
||||
- [hardware/raspberry-pi-zero-w-build.md](hardware/raspberry-pi-zero-w-build.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)
|
||||
@ -109,5 +110,6 @@ Last refreshed: **February 25, 2026**.
|
||||
- [project/README.md](project/README.md)
|
||||
- [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md)
|
||||
- [docs-audit-2026-02-24.md](docs-audit-2026-02-24.md)
|
||||
- [project/m4-5-rfi-spike-2026-02-28.md](project/m4-5-rfi-spike-2026-02-28.md)
|
||||
- [i18n-gap-backlog.md](i18n-gap-backlog.md)
|
||||
- [docs-inventory.md](docs-inventory.md)
|
||||
|
||||
@ -70,22 +70,123 @@ adb shell /data/local/tmp/zeroclaw --version
|
||||
|
||||
## Building from Source
|
||||
|
||||
To build for Android yourself:
|
||||
ZeroClaw supports two Android source-build workflows.
|
||||
|
||||
### A) Build directly inside Termux (on-device)
|
||||
|
||||
Use this when compiling natively on your phone/tablet.
|
||||
|
||||
```bash
|
||||
# Termux prerequisites
|
||||
pkg update
|
||||
pkg install -y clang pkg-config
|
||||
|
||||
# Add Android Rust targets (aarch64 target is enough for most devices)
|
||||
rustup target add aarch64-linux-android armv7-linux-androideabi
|
||||
|
||||
# Build for your current device arch
|
||||
cargo build --release --target aarch64-linux-android
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `.cargo/config.toml` uses `clang` for Android targets by default.
|
||||
- You do not need NDK-prefixed linkers such as `aarch64-linux-android21-clang` for native Termux builds.
|
||||
- The `wasm-tools` runtime is currently unavailable on Android builds; WASM tools fall back to a stub implementation.
|
||||
|
||||
### B) Cross-compile from Linux/macOS with Android NDK
|
||||
|
||||
Use this when building Android binaries from a desktop CI/dev machine.
|
||||
|
||||
```bash
|
||||
# Install Android NDK
|
||||
# Add targets
|
||||
rustup target add armv7-linux-androideabi aarch64-linux-android
|
||||
|
||||
# Set NDK path
|
||||
# Configure Android NDK toolchain
|
||||
export ANDROID_NDK_HOME=/path/to/ndk
|
||||
export PATH=$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH
|
||||
export NDK_TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
||||
export PATH="$NDK_TOOLCHAIN:$PATH"
|
||||
|
||||
# Override Cargo defaults with NDK wrapper linkers
|
||||
export CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER="$NDK_TOOLCHAIN/armv7a-linux-androideabi21-clang"
|
||||
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$NDK_TOOLCHAIN/aarch64-linux-android21-clang"
|
||||
|
||||
# Ensure cc-rs build scripts use the same compilers
|
||||
export CC_armv7_linux_androideabi="$CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER"
|
||||
export CC_aarch64_linux_android="$CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER"
|
||||
|
||||
# Build
|
||||
cargo build --release --target armv7-linux-androideabi
|
||||
cargo build --release --target aarch64-linux-android
|
||||
```
|
||||
|
||||
### Quick environment self-check
|
||||
|
||||
Use the built-in checker to validate linker/toolchain setup before long builds:
|
||||
|
||||
```bash
|
||||
# From repo root
|
||||
scripts/android/termux_source_build_check.sh --target aarch64-linux-android
|
||||
|
||||
# Force Termux-native diagnostics
|
||||
scripts/android/termux_source_build_check.sh --target aarch64-linux-android --mode termux-native
|
||||
|
||||
# Force desktop NDK-cross diagnostics
|
||||
scripts/android/termux_source_build_check.sh --target aarch64-linux-android --mode ndk-cross
|
||||
|
||||
# Run an actual cargo check after environment validation
|
||||
scripts/android/termux_source_build_check.sh --target aarch64-linux-android --run-cargo-check
|
||||
```
|
||||
|
||||
When `--run-cargo-check` fails, the script now analyzes common linker/`cc-rs` errors and prints
|
||||
copy-paste fix commands for the selected mode.
|
||||
|
||||
You can also diagnose a previously captured cargo log directly:
|
||||
|
||||
```bash
|
||||
scripts/android/termux_source_build_check.sh \
|
||||
--target aarch64-linux-android \
|
||||
--mode ndk-cross \
|
||||
--diagnose-log /path/to/cargo-error.log
|
||||
```
|
||||
|
||||
For CI automation, emit a machine-readable report:
|
||||
|
||||
```bash
|
||||
scripts/android/termux_source_build_check.sh \
|
||||
--target aarch64-linux-android \
|
||||
--mode ndk-cross \
|
||||
--diagnose-log /path/to/cargo-error.log \
|
||||
--json-output /tmp/zeroclaw-android-selfcheck.json
|
||||
```
|
||||
|
||||
For pipeline usage, output JSON directly to stdout:
|
||||
|
||||
```bash
|
||||
scripts/android/termux_source_build_check.sh \
|
||||
--target aarch64-linux-android \
|
||||
--mode ndk-cross \
|
||||
--diagnose-log /path/to/cargo-error.log \
|
||||
--json-output - \
|
||||
--quiet
|
||||
```
|
||||
|
||||
JSON report highlights:
|
||||
- `status`: `ok` or `error`
|
||||
- `error_code`: stable classifier (`NONE`, `BAD_ARGUMENT`, `MISSING_DIAGNOSE_LOG`, `CARGO_CHECK_FAILED`, etc.)
|
||||
- `detection_codes`: structured diagnosis codes (`CC_RS_TOOL_NOT_FOUND`, `LINKER_RESOLUTION_FAILURE`, `MISSING_RUST_TARGET_STDLIB`, ...)
|
||||
- `suggestions`: copy-paste recovery commands
|
||||
|
||||
Enable strict gating when integrating into CI:
|
||||
|
||||
```bash
|
||||
scripts/android/termux_source_build_check.sh \
|
||||
--target aarch64-linux-android \
|
||||
--mode ndk-cross \
|
||||
--diagnose-log /path/to/cargo-error.log \
|
||||
--json-output /tmp/zeroclaw-android-selfcheck.json \
|
||||
--strict
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Permission denied"
|
||||
@ -98,6 +199,24 @@ chmod +x zeroclaw
|
||||
|
||||
Make sure you downloaded the correct architecture for your device.
|
||||
|
||||
For native Termux builds, make sure `clang` exists and remove stale NDK overrides:
|
||||
|
||||
```bash
|
||||
unset CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER
|
||||
unset CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER
|
||||
unset CC_aarch64_linux_android
|
||||
unset CC_armv7_linux_androideabi
|
||||
command -v clang
|
||||
```
|
||||
|
||||
For cross-compilation, ensure `ANDROID_NDK_HOME` and `CARGO_TARGET_*_LINKER` point to valid NDK binaries.
|
||||
If build scripts (for example `ring`/`aws-lc-sys`) still report `failed to find tool "aarch64-linux-android-clang"`,
|
||||
also export `CC_aarch64_linux_android` / `CC_armv7_linux_androideabi` to the same NDK clang wrappers.
|
||||
|
||||
### "WASM tools are unavailable on Android"
|
||||
|
||||
This is expected today. Android builds run the WASM tool loader in stub mode; build on Linux/macOS/Windows if you need runtime `wasm-tools` execution.
|
||||
|
||||
### Old Android (4.x)
|
||||
|
||||
Use the `armv7-linux-androideabi` build with API level 16+.
|
||||
|
||||
@ -119,7 +119,7 @@ cargo check --no-default-features --features hardware,channel-matrix
|
||||
cargo check --no-default-features --features hardware,channel-lark
|
||||
```
|
||||
|
||||
If `[channels_config.matrix]`, `[channels_config.lark]`, or `[channels_config.feishu]` is present but the corresponding feature is not compiled in, `zeroclaw channel list`, `zeroclaw channel doctor`, and `zeroclaw channel start` will report that the channel is intentionally skipped for this build.
|
||||
If `[channels_config.matrix]`, `[channels_config.lark]`, or `[channels_config.feishu]` is present but the corresponding feature is not compiled in, `zeroclaw channel list`, `zeroclaw channel doctor`, and `zeroclaw channel start` will report that the channel is intentionally skipped for this build. The same applies to cron delivery: setting `delivery.channel` to a feature-gated channel in a build without that feature will return an error at delivery time. For Matrix cron delivery, only plain rooms are supported; E2EE rooms require listener sessions via `zeroclaw daemon`.
|
||||
|
||||
---
|
||||
|
||||
@ -143,6 +143,7 @@ If `[channels_config.matrix]`, `[channels_config.lark]`, or `[channels_config.fe
|
||||
| Feishu | websocket (default) or webhook | Webhook mode only |
|
||||
| DingTalk | stream mode | No |
|
||||
| QQ | bot gateway | No |
|
||||
| Napcat | websocket receive + HTTP send (OneBot) | No (typically local/LAN) |
|
||||
| Linq | webhook (`/linq`) | Yes (public HTTPS callback) |
|
||||
| iMessage | local integration | No |
|
||||
| Nostr | relay websocket (NIP-04 / NIP-17) | No |
|
||||
@ -159,7 +160,7 @@ For channels with inbound sender allowlists:
|
||||
|
||||
Field names differ by channel:
|
||||
|
||||
- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/Feishu/DingTalk/QQ/Nextcloud Talk)
|
||||
- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/Feishu/DingTalk/QQ/Napcat/Nextcloud Talk)
|
||||
- `allowed_from` (Signal)
|
||||
- `allowed_numbers` (WhatsApp)
|
||||
- `allowed_senders` (Email/Linq)
|
||||
@ -201,6 +202,7 @@ stream_mode = "off" # optional: off | partial
|
||||
draft_update_interval_ms = 1000 # optional: edit throttle for partial streaming
|
||||
mention_only = false # legacy fallback; used when group_reply.mode is not set
|
||||
interrupt_on_new_message = false # optional: cancel in-flight same-sender same-chat request
|
||||
ack_enabled = true # optional: send emoji reaction acknowledgments (default: true)
|
||||
|
||||
[channels_config.telegram.group_reply]
|
||||
mode = "all_messages" # optional: all_messages | mention_only
|
||||
@ -211,6 +213,7 @@ Telegram notes:
|
||||
|
||||
- `interrupt_on_new_message = true` preserves interrupted user turns in conversation history, then restarts generation on the newest message.
|
||||
- Interruption scope is strict: same sender in the same chat. Messages from different chats are processed independently.
|
||||
- `ack_enabled = false` disables the emoji reaction (⚡️, 👌, 👀, 🔥, 👍) sent to incoming messages as acknowledgment.
|
||||
|
||||
### 4.2 Discord
|
||||
|
||||
@ -349,8 +352,12 @@ password = "email-password"
|
||||
from_address = "bot@example.com"
|
||||
poll_interval_secs = 60
|
||||
allowed_senders = ["*"]
|
||||
imap_id = { enabled = true, name = "zeroclaw", version = "0.1.7", vendor = "zeroclaw-labs" }
|
||||
```
|
||||
|
||||
`imap_id` sends RFC 2971 client metadata right after IMAP login. This is required by some providers
|
||||
(for example NetEase `163.com` / `126.com`) before mailbox selection is allowed.
|
||||
|
||||
### 4.10 IRC
|
||||
|
||||
```toml
|
||||
@ -470,7 +477,26 @@ Notes:
|
||||
- `X-Bot-Appid` is checked when present and must match `app_id`.
|
||||
- Set `receive_mode = "websocket"` to keep the legacy gateway WS receive path.
|
||||
|
||||
### 4.16 Nextcloud Talk
|
||||
### 4.16 Napcat (QQ via OneBot)
|
||||
|
||||
```toml
|
||||
[channels_config.napcat]
|
||||
websocket_url = "ws://127.0.0.1:3001"
|
||||
api_base_url = "http://127.0.0.1:3001" # optional; auto-derived when omitted
|
||||
access_token = "" # optional
|
||||
allowed_users = ["*"]
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Inbound messages are consumed from Napcat's WebSocket stream.
|
||||
- Outbound sends use OneBot-compatible HTTP endpoints (`send_private_msg` / `send_group_msg`).
|
||||
- Recipients:
|
||||
- `user:<qq_user_id>` for private messages
|
||||
- `group:<qq_group_id>` for group messages
|
||||
- Outbound reply chaining uses incoming message ids via CQ reply tags.
|
||||
|
||||
### 4.17 Nextcloud Talk
|
||||
|
||||
```toml
|
||||
[channels_config.nextcloud_talk]
|
||||
@ -488,7 +514,7 @@ Notes:
|
||||
- `ZEROCLAW_NEXTCLOUD_TALK_WEBHOOK_SECRET` overrides config secret.
|
||||
- See [nextcloud-talk-setup.md](./nextcloud-talk-setup.md) for a full runbook.
|
||||
|
||||
### 4.16 Linq
|
||||
### 4.18 Linq
|
||||
|
||||
```toml
|
||||
[channels_config.linq]
|
||||
@ -507,7 +533,7 @@ Notes:
|
||||
- `ZEROCLAW_LINQ_SIGNING_SECRET` overrides config secret.
|
||||
- `allowed_senders` uses E.164 phone number format (e.g. `+1234567890`).
|
||||
|
||||
### 4.17 iMessage
|
||||
### 4.19 iMessage
|
||||
|
||||
```toml
|
||||
[channels_config.imessage]
|
||||
|
||||
@ -19,11 +19,12 @@ Last verified: **February 28, 2026**.
|
||||
| `cron` | Manage scheduled tasks |
|
||||
| `models` | Refresh provider model catalogs |
|
||||
| `providers` | List provider IDs, aliases, and active provider |
|
||||
| `providers-quota` | Check provider quota usage, rate limits, and health |
|
||||
| `channel` | Manage channels and channel health checks |
|
||||
| `integrations` | Inspect integration details |
|
||||
| `skills` | List/install/remove skills |
|
||||
| `migrate` | Import from external runtimes (currently OpenClaw) |
|
||||
| `config` | Export machine-readable config schema |
|
||||
| `config` | Inspect, query, and modify runtime configuration |
|
||||
| `completions` | Generate shell completion scripts to stdout |
|
||||
| `hardware` | Discover and introspect USB hardware |
|
||||
| `peripheral` | Configure and flash peripherals |
|
||||
@ -121,6 +122,24 @@ Notes:
|
||||
|
||||
`models refresh` currently supports live catalog refresh for provider IDs: `openrouter`, `openai`, `anthropic`, `groq`, `mistral`, `deepseek`, `xai`, `together-ai`, `gemini`, `ollama`, `llamacpp`, `sglang`, `vllm`, `astrai`, `venice`, `fireworks`, `cohere`, `moonshot`, `glm`, `zai`, `qwen`, `volcengine` (`doubao`/`ark` aliases), `siliconflow`, and `nvidia`.
|
||||
|
||||
#### Live model availability test
|
||||
|
||||
```bash
|
||||
./dev/test_models.sh # test all Gemini models + profile rotation
|
||||
./dev/test_models.sh models # test model availability only
|
||||
./dev/test_models.sh profiles # test profile rotation only
|
||||
```
|
||||
|
||||
Runs a Rust integration test (`tests/gemini_model_availability.rs`) that verifies each model against the OAuth endpoint (cloudcode-pa). Requires valid Gemini OAuth credentials in `auth-profiles.json`.
|
||||
|
||||
### `providers-quota`
|
||||
|
||||
- `zeroclaw providers-quota` — show quota status for all configured providers
|
||||
- `zeroclaw providers-quota --provider gemini` — show quota for a specific provider
|
||||
- `zeroclaw providers-quota --format json` — JSON output for scripting
|
||||
|
||||
Displays provider quota usage, rate limits, circuit breaker state, and OAuth profile health.
|
||||
|
||||
### `doctor`
|
||||
|
||||
- `zeroclaw doctor`
|
||||
@ -248,8 +267,17 @@ Skill manifests (`SKILL.toml`) support `prompts` and `[[tools]]`; both are injec
|
||||
|
||||
### `config`
|
||||
|
||||
- `zeroclaw config show`
|
||||
- `zeroclaw config get <key>`
|
||||
- `zeroclaw config set <key> <value>`
|
||||
- `zeroclaw config schema`
|
||||
|
||||
`config show` prints the full effective configuration as pretty JSON with secrets masked as `***REDACTED***`. Environment variable overrides are already applied.
|
||||
|
||||
`config get <key>` queries a single value by dot-separated path (e.g. `gateway.port`, `security.estop.enabled`). Scalars print raw values; objects and arrays print pretty JSON. Sensitive fields are masked.
|
||||
|
||||
`config set <key> <value>` updates a configuration value and persists it atomically to `config.toml`. Types are inferred automatically (`true`/`false` → bool, integers, floats, JSON syntax → object/array, otherwise string). Type mismatches are rejected before writing.
|
||||
|
||||
`config schema` prints a JSON Schema (draft 2020-12) for the full `config.toml` contract to stdout.
|
||||
|
||||
### `completions`
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
This is a high-signal reference for common config sections and defaults.
|
||||
|
||||
Last verified: **February 25, 2026**.
|
||||
Last verified: **February 28, 2026**.
|
||||
|
||||
Config path resolution at startup:
|
||||
|
||||
@ -14,9 +14,12 @@ ZeroClaw logs the resolved config on startup at `INFO` level:
|
||||
|
||||
- `Config loaded` with fields: `path`, `workspace`, `source`, `initialized`
|
||||
|
||||
Schema export command:
|
||||
CLI commands for config inspection and modification:
|
||||
|
||||
- `zeroclaw config schema` (prints JSON Schema draft 2020-12 to stdout)
|
||||
- `zeroclaw config show` — print effective config as JSON (secrets masked)
|
||||
- `zeroclaw config get <key>` — query a value by dot-path (e.g. `zeroclaw config get gateway.port`)
|
||||
- `zeroclaw config set <key> <value>` — update a value and save to `config.toml`
|
||||
- `zeroclaw config schema` — print JSON Schema (draft 2020-12) to stdout
|
||||
|
||||
## Core Keys
|
||||
|
||||
@ -35,6 +38,38 @@ Notes:
|
||||
- Unset keeps the provider's built-in default.
|
||||
- Environment override: `ZEROCLAW_MODEL_SUPPORT_VISION` or `MODEL_SUPPORT_VISION` (values: `true`/`false`/`1`/`0`/`yes`/`no`/`on`/`off`).
|
||||
|
||||
## `[model_providers.<profile>]`
|
||||
|
||||
Use named profiles to map a logical provider id to a provider name/base URL and optional profile-scoped credentials.
|
||||
|
||||
| Key | Default | Notes |
|
||||
|---|---|---|
|
||||
| `name` | unset | Optional provider id override (for example `openai`, `openai-codex`) |
|
||||
| `base_url` | unset | Optional OpenAI-compatible endpoint URL |
|
||||
| `wire_api` | unset | Optional protocol mode: `responses` or `chat_completions` |
|
||||
| `model` | unset | Optional profile-scoped default model |
|
||||
| `api_key` | unset | Optional profile-scoped API key (used when top-level `api_key` is empty) |
|
||||
| `requires_openai_auth` | `false` | Load OpenAI auth material (`OPENAI_API_KEY` / Codex auth file) |
|
||||
|
||||
Notes:
|
||||
|
||||
- If both top-level `api_key` and profile `api_key` are present, top-level `api_key` wins.
|
||||
- If top-level `default_model` is still the global OpenRouter default, profile `model` is used as an automatic compatibility override.
|
||||
- Secrets encryption applies to profile API keys when `secrets.encrypt = true`.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
default_provider = "sub2api"
|
||||
|
||||
[model_providers.sub2api]
|
||||
name = "sub2api"
|
||||
base_url = "https://api.example.com/v1"
|
||||
wire_api = "chat_completions"
|
||||
model = "qwen-max"
|
||||
api_key = "sk-profile-key"
|
||||
```
|
||||
|
||||
## `[observability]`
|
||||
|
||||
| Key | Default | Purpose |
|
||||
@ -309,6 +344,32 @@ min_prompt_chars = 40
|
||||
symbol_ratio_threshold = 0.25
|
||||
```
|
||||
|
||||
## `[security.outbound_leak_guard]`
|
||||
|
||||
Controls outbound credential leak handling for channel replies after tool-output sanitization.
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `enabled` | `true` | Enable outbound credential leak scanning on channel responses |
|
||||
| `action` | `redact` | Leak handling mode: `redact` (mask and deliver) or `block` (do not deliver original content) |
|
||||
| `sensitivity` | `0.7` | Leak detector sensitivity (`0.0` to `1.0`, higher is more aggressive) |
|
||||
|
||||
Notes:
|
||||
|
||||
- Detection uses the same leak detector used by existing redaction guardrails (API keys, JWTs, private keys, high-entropy tokens, etc.).
|
||||
- `action = "redact"` preserves current behavior (safe-by-default compatibility).
|
||||
- `action = "block"` is stricter and returns a safe fallback message instead of potentially sensitive content.
|
||||
- When this guard is enabled, `/v1/chat/completions` streaming responses are safety-buffered and emitted after sanitization to avoid leaking raw token deltas before final scan.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[security.outbound_leak_guard]
|
||||
enabled = true
|
||||
action = "block"
|
||||
sensitivity = 0.9
|
||||
```
|
||||
|
||||
## `[agents.<name>]`
|
||||
|
||||
Delegate sub-agent configurations. Each key under `[agents]` defines a named sub-agent that the primary agent can delegate to.
|
||||
@ -605,6 +666,7 @@ Notes:
|
||||
| `max_response_size` | `1000000` | Maximum response size in bytes (default: 1 MB) |
|
||||
| `timeout_secs` | `30` | Request timeout in seconds |
|
||||
| `user_agent` | `ZeroClaw/1.0` | User-Agent header for outbound HTTP requests |
|
||||
| `credential_profiles` | `{}` | Optional named env-backed auth profiles used by tool arg `credential_profile` |
|
||||
|
||||
Notes:
|
||||
|
||||
@ -612,6 +674,36 @@ Notes:
|
||||
- Use exact domain or subdomain matching (e.g. `"api.example.com"`, `"example.com"`), or `"*"` to allow any public domain.
|
||||
- Local/private targets are still blocked even when `"*"` is configured.
|
||||
- Shell `curl`/`wget` are classified as high-risk and may be blocked by autonomy policy. Prefer `http_request` for direct HTTP calls.
|
||||
- `credential_profiles` lets the harness inject auth headers from environment variables, so agents can call authenticated APIs without raw tokens in tool arguments.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[http_request]
|
||||
enabled = true
|
||||
allowed_domains = ["api.github.com", "api.linear.app"]
|
||||
|
||||
[http_request.credential_profiles.github]
|
||||
header_name = "Authorization"
|
||||
env_var = "GITHUB_TOKEN"
|
||||
value_prefix = "Bearer "
|
||||
|
||||
[http_request.credential_profiles.linear]
|
||||
header_name = "Authorization"
|
||||
env_var = "LINEAR_API_KEY"
|
||||
value_prefix = ""
|
||||
```
|
||||
|
||||
Then call `http_request` with:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://api.github.com/user",
|
||||
"credential_profile": "github"
|
||||
}
|
||||
```
|
||||
|
||||
When using `credential_profile`, do not also set the same header key in `args.headers` (case-insensitive), or the request will be rejected as a header conflict.
|
||||
|
||||
## `[web_fetch]`
|
||||
|
||||
@ -769,6 +861,8 @@ Environment overrides:
|
||||
| `max_cost_per_day_cents` | `500` | per-policy spend guardrail |
|
||||
| `require_approval_for_medium_risk` | `true` | approval gate for medium-risk commands |
|
||||
| `block_high_risk_commands` | `true` | hard block for high-risk commands |
|
||||
| `allow_sensitive_file_reads` | `false` | allow `file_read` on sensitive files/dirs (for example `.env`, `.aws/credentials`, private keys) |
|
||||
| `allow_sensitive_file_writes` | `false` | allow `file_write`/`file_edit` on sensitive files/dirs (for example `.env`, `.aws/credentials`, private keys) |
|
||||
| `auto_approve` | `[]` | tool operations always auto-approved |
|
||||
| `always_ask` | `[]` | tool operations that always require approval |
|
||||
| `non_cli_excluded_tools` | `[]` | tools hidden from non-CLI channel tool specs |
|
||||
@ -782,6 +876,9 @@ Notes:
|
||||
- Access outside the workspace requires `allowed_roots`, even when `workspace_only = false`.
|
||||
- `allowed_roots` supports absolute paths, `~/...`, and workspace-relative paths.
|
||||
- `allowed_commands` entries can be command names (for example, `"git"`), explicit executable paths (for example, `"/usr/bin/antigravity"`), or `"*"` to allow any command name/path (risk gates still apply).
|
||||
- `file_read` blocks sensitive secret-bearing files/directories by default. Set `allow_sensitive_file_reads = true` only for controlled debugging sessions.
|
||||
- `file_write` and `file_edit` block sensitive secret-bearing files/directories by default. Set `allow_sensitive_file_writes = true` only for controlled break-glass sessions.
|
||||
- `file_read`, `file_write`, and `file_edit` refuse multiply-linked files (hard-link guard) to reduce workspace path bypass risk via hard-link escapes.
|
||||
- Shell separator/operator parsing is quote-aware. Characters like `;` inside quoted arguments are treated as literals, not command separators.
|
||||
- Unquoted shell chaining/operators are still enforced by policy checks (`;`, `|`, `&&`, `||`, background chaining, and redirects).
|
||||
- In supervised mode on non-CLI channels, operators can persist human-approved tools with:
|
||||
@ -826,6 +923,17 @@ allowed_roots = ["~/Desktop/projects", "/opt/shared-repo"]
|
||||
Notes:
|
||||
|
||||
- Memory context injection ignores legacy `assistant_resp*` auto-save keys to prevent old model-authored summaries from being treated as facts.
|
||||
- Observation memory is available via tool `memory_observe`, which stores entries under category `observation` by default (override with `category` when needed).
|
||||
|
||||
Example (tool-call payload):
|
||||
|
||||
```json
|
||||
{
|
||||
"observation": "User asks for brief release notes when CI is green.",
|
||||
"source": "chat",
|
||||
"confidence": 0.9
|
||||
}
|
||||
```
|
||||
|
||||
## `[[model_routes]]` and `[[embedding_routes]]`
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
This inventory classifies documentation by intent and canonical location.
|
||||
|
||||
Last reviewed: **February 24, 2026**.
|
||||
Last reviewed: **February 28, 2026**.
|
||||
|
||||
## Classification Legend
|
||||
|
||||
@ -124,6 +124,7 @@ These are valuable context, but **not strict runtime contracts**.
|
||||
|---|---|
|
||||
| `docs/project-triage-snapshot-2026-02-18.md` | Snapshot |
|
||||
| `docs/docs-audit-2026-02-24.md` | Snapshot (docs architecture audit) |
|
||||
| `docs/project/m4-5-rfi-spike-2026-02-28.md` | Snapshot (M4-5 workspace split RFI baseline and execution plan) |
|
||||
| `docs/i18n-gap-backlog.md` | Snapshot (i18n depth gap tracking) |
|
||||
|
||||
## Maintenance Contract
|
||||
|
||||
@ -7,6 +7,7 @@ ZeroClaw's hardware subsystem enables direct control of microcontrollers and per
|
||||
## Entry Points
|
||||
|
||||
- Architecture and peripheral model: [../hardware-peripherals-design.md](../hardware-peripherals-design.md)
|
||||
- Raspberry Pi Zero W build: [raspberry-pi-zero-w-build.md](raspberry-pi-zero-w-build.md)
|
||||
- Add a new board/tool: [../adding-boards-and-tools.md](../adding-boards-and-tools.md)
|
||||
- Nucleo setup: [../nucleo-setup.md](../nucleo-setup.md)
|
||||
- Arduino Uno R4 WiFi setup: [../arduino-uno-q-setup.md](../arduino-uno-q-setup.md)
|
||||
|
||||
466
docs/hardware/raspberry-pi-zero-w-build.md
Normal file
466
docs/hardware/raspberry-pi-zero-w-build.md
Normal file
@ -0,0 +1,466 @@
|
||||
# Building ZeroClaw on Raspberry Pi Zero W
|
||||
|
||||
Complete guide to compile ZeroClaw on Raspberry Pi Zero W (512MB RAM, ARMv6).
|
||||
|
||||
Last verified: **February 28, 2026**.
|
||||
|
||||
## Overview
|
||||
|
||||
The Raspberry Pi Zero W is a constrained device with only **512MB of RAM**. Compiling Rust on this device requires special considerations:
|
||||
|
||||
| Requirement | Minimum | Recommended |
|
||||
|-------------|---------|-------------|
|
||||
| RAM | 512MB | 512MB + 2GB swap |
|
||||
| Free disk | 4GB | 6GB+ |
|
||||
| OS | Raspberry Pi OS (32-bit) | Raspberry Pi OS Lite (32-bit) |
|
||||
| Architecture | armv6l | armv6l |
|
||||
|
||||
**Important:** This guide assumes you are building **natively on the Pi Zero W**, not cross-compiling from a more powerful machine.
|
||||
|
||||
## Target Abi: gnueabihf vs musleabihf
|
||||
|
||||
When building for Raspberry Pi Zero W, you have two target ABI choices:
|
||||
|
||||
| ABI | Full Target | Description | Binary Size | Static Linking | Recommended |
|
||||
|-----|-------------|-------------|-------------|----------------|-------------|
|
||||
| **musleabihf** | `armv6l-unknown-linux-musleabihf` | Uses musl libc | Smaller | Yes (fully static) | **Yes** |
|
||||
| gnueabihf | `armv6l-unknown-linux-gnueabihf` | Uses glibc | Larger | Partial | No |
|
||||
|
||||
**Why musleabihf is preferred:**
|
||||
|
||||
1. **Smaller binary size** — musl produces more compact binaries, critical for embedded devices
|
||||
2. **Fully static linking** — No runtime dependency on system libc versions; binary works across different Raspberry Pi OS versions
|
||||
3. **Better security** — Smaller attack surface with musl's minimal libc implementation
|
||||
4. **Portability** — Static binary runs on any ARMv6 Linux distribution without compatibility concerns
|
||||
|
||||
**Trade-offs:**
|
||||
- musleabihf builds may take slightly longer to compile
|
||||
- Some niche dependencies may not support musl (ZeroClaw's dependencies are musl-compatible)
|
||||
|
||||
## Option A: Native Compilation
|
||||
|
||||
### Step 1: Prepare System
|
||||
|
||||
First, ensure your system is up to date:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt upgrade -y
|
||||
```
|
||||
|
||||
### Step 2: Add Swap Space (Critical)
|
||||
|
||||
Due to limited RAM (512MB), **adding swap is mandatory** for successful compilation:
|
||||
|
||||
```bash
|
||||
# Create 2GB swap file
|
||||
sudo fallocate -l 2G /swapfile
|
||||
|
||||
# Set proper permissions
|
||||
sudo chmod 600 /swapfile
|
||||
|
||||
# Format as swap
|
||||
sudo mkswap /swapfile
|
||||
|
||||
# Enable swap
|
||||
sudo swapon /swapfile
|
||||
|
||||
# Verify swap is active
|
||||
free -h
|
||||
```
|
||||
|
||||
To make swap persistent across reboots:
|
||||
|
||||
```bash
|
||||
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
|
||||
```
|
||||
|
||||
### Step 3: Install Rust Toolchain
|
||||
|
||||
Install Rust via rustup:
|
||||
|
||||
```bash
|
||||
# Install rustup
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
|
||||
# Source the environment
|
||||
source $HOME/.cargo/env
|
||||
|
||||
# Verify installation
|
||||
rustc --version
|
||||
cargo --version
|
||||
```
|
||||
|
||||
### Step 4: Install Build Dependencies
|
||||
|
||||
Install required system packages:
|
||||
|
||||
```bash
|
||||
sudo apt install -y \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
libsqlite3-dev \
|
||||
git \
|
||||
curl
|
||||
```
|
||||
|
||||
### Step 5: Clone ZeroClaw Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zeroclaw-labs/zeroclaw.git
|
||||
cd zeroclaw
|
||||
```
|
||||
|
||||
Or if you already have the repository:
|
||||
|
||||
```bash
|
||||
cd /path/to/zeroclaw
|
||||
git fetch --all
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
### Step 6: Configure Build for Low Memory
|
||||
|
||||
ZeroClaw's `Cargo.toml` is already configured for low-memory devices (`codegen-units = 1` in release profile). For additional safety on Pi Zero W:
|
||||
|
||||
```bash
|
||||
# Set CARGO_BUILD_JOBS=1 to prevent memory exhaustion
|
||||
export CARGO_BUILD_JOBS=1
|
||||
```
|
||||
|
||||
### Step 7: Choose Target ABI and Build ZeroClaw
|
||||
|
||||
This step will take **30-60 minutes** depending on your storage speed and chosen target.
|
||||
|
||||
**For native build, the default target is gnueabihf (matches your system):**
|
||||
|
||||
```bash
|
||||
# Build with default target (gnueabihf)
|
||||
cargo build --release
|
||||
|
||||
# Alternative: Build with specific features only (smaller binary)
|
||||
cargo build --release --no-default-features --features "wasm-tools"
|
||||
```
|
||||
|
||||
**For musleabihf (smaller, static binary — requires musl tools):**
|
||||
|
||||
```bash
|
||||
# Install musl development tools
|
||||
sudo apt install -y musl-tools musl-dev
|
||||
|
||||
# Add musl target
|
||||
rustup target add armv6l-unknown-linux-musleabihf
|
||||
|
||||
# Build for musleabihf (smaller, static binary)
|
||||
cargo build --release --target armv6l-unknown-linux-musleabihf
|
||||
```
|
||||
|
||||
**Note:** If the build fails with "out of memory" errors, you may need to increase swap size to 4GB:
|
||||
|
||||
```bash
|
||||
sudo swapoff /swapfile
|
||||
sudo rm /swapfile
|
||||
sudo fallocate -l 4G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
Then retry the build.
|
||||
|
||||
### Step 8: Install ZeroClaw
|
||||
|
||||
```bash
|
||||
# For gnueabihf (default target)
|
||||
sudo cp target/release/zeroclaw /usr/local/bin/
|
||||
|
||||
# For musleabihf
|
||||
sudo cp target/armv6l-unknown-linux-musleabihf/release/zeroclaw /usr/local/bin/
|
||||
|
||||
# Verify installation
|
||||
zeroclaw --version
|
||||
|
||||
# Verify binary is statically linked (musleabihf only)
|
||||
file /usr/local/bin/zeroclaw
|
||||
# Should show "statically linked" for musleabihf
|
||||
```
|
||||
|
||||
## Option B: Cross-Compilation (Recommended)
|
||||
|
||||
For faster builds, cross-compile from a more powerful machine (Linux, macOS, or Windows). A native build on Pi Zero W can take **30-60 minutes**, while cross-compilation typically completes in **5-10 minutes**.
|
||||
|
||||
### Why Cross-Compile?
|
||||
|
||||
| Factor | Native (Pi Zero W) | Cross-Compile (x86_64) |
|
||||
|--------|-------------------|------------------------|
|
||||
| Build time | 30-60 minutes | 5-10 minutes |
|
||||
| RAM required | 512MB + 2GB swap | 4GB+ typical |
|
||||
| CPU load | High (single core) | Low relative to host |
|
||||
| Iteration speed | Slow | Fast |
|
||||
|
||||
### Prerequisites
|
||||
|
||||
On your build host (Linux x86_64 example):
|
||||
|
||||
```bash
|
||||
# Install ARM cross-compilation toolchain
|
||||
# Note: We use gcc-arm-linux-gnueabihf as the linker tool,
|
||||
# but Rust's target configuration produces a static musl binary
|
||||
sudo apt install -y musl-tools musl-dev gcc-arm-linux-gnueabihf
|
||||
|
||||
# Verify cross-compiler is available
|
||||
arm-linux-gnueabihf-gcc --version
|
||||
```
|
||||
|
||||
**Why gnueabihf for musl builds?**
|
||||
|
||||
Pure `arm-linux-musleabihf-gcc` cross-compilers are not available in standard Ubuntu/Debian repositories. The workaround:
|
||||
1. Use `gcc-arm-linux-gnueabihf` as the linker tool (available in repos)
|
||||
2. Rust's target spec (`armv6l-unknown-linux-musleabihf.json`) sets `env: "musl"`
|
||||
3. Static linking (`-C link-arg=-static`) eliminates glibc dependency
|
||||
4. Result: a portable static musl binary that works on any ARMv6 Linux
|
||||
|
||||
**macOS:** Install via Homebrew:
|
||||
```bash
|
||||
brew install musl-cross
|
||||
```
|
||||
|
||||
**Windows:** Use WSL2 or install mingw-w64 cross-compilers.
|
||||
|
||||
### Build for musleabihf (Recommended)
|
||||
|
||||
The ZeroClaw repository includes pre-configured `.cargo/config.toml` and `.cargo/armv6l-unknown-linux-musleabihf.json` for static linking.
|
||||
|
||||
```bash
|
||||
# Clone ZeroClaw repository
|
||||
git clone https://github.com/zeroclaw-labs/zeroclaw.git
|
||||
cd zeroclaw
|
||||
|
||||
# Add ARMv6 musl target to rustup
|
||||
rustup target add armv6l-unknown-linux-musleabihf
|
||||
|
||||
# The repository's .cargo/config.toml already contains:
|
||||
# [target.armv6l-unknown-linux-musleabihf]
|
||||
# rustflags = ["-C", "link-arg=-static"]
|
||||
#
|
||||
# And .cargo/armv6l-unknown-linux-musleabihf.json provides
|
||||
# the target specification for proper ARMv6 support.
|
||||
|
||||
# Build for target (static binary, no runtime dependencies)
|
||||
cargo build --release --target armv6l-unknown-linux-musleabihf
|
||||
```
|
||||
|
||||
### Understanding Static Linking Benefits
|
||||
|
||||
The `rustflags = ["-C", "link-arg=-static"]` flag ensures **fully static linking**:
|
||||
|
||||
| Benefit | Description |
|
||||
|---------|-------------|
|
||||
| **No libc dependency** | Binary works on any ARMv6 Linux distribution |
|
||||
| **Smaller size** | musl produces more compact binaries than glibc |
|
||||
| **Version-agnostic** | Runs on Raspberry Pi OS Bullseye, Bookworm, or future versions |
|
||||
| **Secure by default** | Reduced attack surface with musl's minimal libc |
|
||||
| **Portable** | Same binary works across different Pi models with ARMv6 |
|
||||
|
||||
### Verify Static Linking
|
||||
|
||||
After building, confirm the binary is statically linked:
|
||||
|
||||
```bash
|
||||
file target/armv6l-unknown-linux-musleabihf/release/zeroclaw
|
||||
# Output should include: "statically linked"
|
||||
|
||||
ldd target/armv6l-unknown-linux-musleabihf/release/zeroclaw
|
||||
# Output should be: "not a dynamic executable"
|
||||
```
|
||||
|
||||
### Build for gnueabihf (Alternative)
|
||||
|
||||
If you need dynamic linking or have specific glibc dependencies:
|
||||
|
||||
```bash
|
||||
# Add ARMv6 glibc target
|
||||
rustup target add armv6l-unknown-linux-gnueabihf
|
||||
|
||||
# Build for target
|
||||
cargo build --release --target armv6l-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
**Note:** gnueabihf binaries will be larger and depend on the target system's glibc version.
|
||||
|
||||
### Build with Custom Features
|
||||
|
||||
Reduce binary size by building only needed features:
|
||||
|
||||
```bash
|
||||
# Minimal build (agent core only)
|
||||
cargo build --release --target armv6l-unknown-linux-musleabihf --no-default-features
|
||||
|
||||
# Specific feature set
|
||||
cargo build --release --target armv6l-unknown-linux-musleabihf --features "telegram,discord"
|
||||
|
||||
# Use dist profile for size-optimized binary
|
||||
cargo build --profile dist --target armv6l-unknown-linux-musleabihf
|
||||
```
|
||||
|
||||
### Transfer to Pi Zero W
|
||||
|
||||
```bash
|
||||
# From build machine (adjust target as needed)
|
||||
scp target/armv6l-unknown-linux-musleabihf/release/zeroclaw pi@zero-w-ip:/home/pi/
|
||||
|
||||
# On Pi Zero W
|
||||
sudo mv ~/zeroclaw /usr/local/bin/
|
||||
sudo chmod +x /usr/local/bin/zeroclaw
|
||||
zeroclaw --version
|
||||
|
||||
# Verify it's statically linked (no dependencies on target system)
|
||||
ldd /usr/local/bin/zeroclaw
|
||||
# Should output: "not a dynamic executable"
|
||||
```
|
||||
|
||||
### Cross-Compilation Workflow Summary
|
||||
|
||||
```
|
||||
┌─────────────────┐ Clone/Fork ┌─────────────────────┐
|
||||
│ ZeroClaw Repo │ ──────────────────> │ Your Build Host │
|
||||
│ (GitHub) │ │ (Linux/macOS/Win) │
|
||||
└─────────────────┘ └─────────────────────┘
|
||||
│
|
||||
│ rustup target add
|
||||
│ cargo build --release
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Static Binary │
|
||||
│ (armv6l-musl) │
|
||||
└─────────────────────┘
|
||||
│
|
||||
│ scp / rsync
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Raspberry Pi │
|
||||
│ Zero W │
|
||||
│ /usr/local/bin/ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Post-Installation Configuration
|
||||
|
||||
### Initialize ZeroClaw
|
||||
|
||||
```bash
|
||||
# Run interactive setup
|
||||
zeroclaw setup
|
||||
|
||||
# Or configure manually
|
||||
mkdir -p ~/.config/zeroclaw
|
||||
nano ~/.config/zeroclaw/config.toml
|
||||
```
|
||||
|
||||
### Enable Hardware Features (Optional)
|
||||
|
||||
For Raspberry Pi GPIO support:
|
||||
|
||||
```bash
|
||||
# Build with peripheral-rpi feature (native build only)
|
||||
cargo build --release --features peripheral-rpi
|
||||
```
|
||||
|
||||
### Run as System Service (Optional)
|
||||
|
||||
Create a systemd service:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/zeroclaw.service
|
||||
```
|
||||
|
||||
Add the following:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=ZeroClaw AI Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
WorkingDirectory=/home/pi
|
||||
ExecStart=/usr/local/bin/zeroclaw agent
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable zeroclaw
|
||||
sudo systemctl start zeroclaw
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Fails with "Out of Memory"
|
||||
|
||||
**Solution:** Increase swap size:
|
||||
|
||||
```bash
|
||||
sudo swapoff /swapfile
|
||||
sudo fallocate -l 4G /swapfile
|
||||
sudo chmod 600 /swapfile
|
||||
sudo mkswap /swapfile
|
||||
sudo swapon /swapfile
|
||||
```
|
||||
|
||||
### Linker Errors
|
||||
|
||||
**Solution:** Ensure proper toolchain is installed:
|
||||
|
||||
```bash
|
||||
sudo apt install -y build-essential pkg-config libssl-dev
|
||||
```
|
||||
|
||||
### SSL/TLS Errors at Runtime
|
||||
|
||||
**Solution:** Install SSL certificates:
|
||||
|
||||
```bash
|
||||
sudo apt install -y ca-certificates
|
||||
```
|
||||
|
||||
### Binary Too Large
|
||||
|
||||
**Solution:** Build with minimal features:
|
||||
|
||||
```bash
|
||||
cargo build --release --no-default-features --features "wasm-tools"
|
||||
```
|
||||
|
||||
Or use the `.dist` profile:
|
||||
|
||||
```bash
|
||||
cargo build --profile dist
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use Lite OS:** Raspberry Pi OS Lite has lower overhead
|
||||
2. **Overclock (Optional):** Add `arm_freq=1000` to `/boot/config.txt`
|
||||
3. **Disable GUI:** `sudo systemctl disable lightdm` (if using desktop)
|
||||
4. **Use external storage:** Build on USB 3.0 drive if available
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Hardware Peripherals Design](../hardware-peripherals-design.md) - Architecture
|
||||
- [One-Click Bootstrap](../one-click-bootstrap.md) - General installation
|
||||
- [Operations Runbook](../operations/operations-runbook.md) - Running in production
|
||||
|
||||
## References
|
||||
|
||||
- [Raspberry Pi Zero W Specifications](https://www.raspberrypi.com/products/raspberry-pi-zero-w/)
|
||||
- [Rust Cross-Compilation Guide](https://rust-lang.github.io/rustc/platform-support.html)
|
||||
- [Cargo Profile Configuration](https://doc.rust-lang.org/cargo/reference/profiles.html)
|
||||
@ -22,7 +22,7 @@ Xác minh lần cuối: **2026-02-28**.
|
||||
| `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 |
|
||||
| `config` | Kiểm tra, truy vấn và sửa đổi cấu hình runtime |
|
||||
| `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 |
|
||||
@ -124,8 +124,17 @@ Skill manifest (`SKILL.toml`) hỗ trợ `prompts` và `[[tools]]`; cả hai đ
|
||||
|
||||
### `config`
|
||||
|
||||
- `zeroclaw config show`
|
||||
- `zeroclaw config get <key>`
|
||||
- `zeroclaw config set <key> <value>`
|
||||
- `zeroclaw config schema`
|
||||
|
||||
`config show` xuất toàn bộ cấu hình hiệu lực dưới dạng JSON với các trường nhạy cảm được ẩn thành `***REDACTED***`. Các ghi đè từ biến môi trường đã được áp dụng.
|
||||
|
||||
`config get <key>` truy vấn một giá trị theo đường dẫn phân tách bằng dấu chấm (ví dụ: `gateway.port`, `security.estop.enabled`). Giá trị đơn in trực tiếp; đối tượng và mảng in dạng JSON.
|
||||
|
||||
`config set <key> <value>` cập nhật giá trị cấu hình và lưu nguyên tử vào `config.toml`. Kiểu dữ liệu được suy luận tự động (`true`/`false` → bool, số nguyên, số thực, cú pháp JSON → đối tượng/mảng, còn lại → chuỗi). Sai kiểu sẽ bị từ chối trước khi ghi.
|
||||
|
||||
`config schema` xuất JSON Schema (draft 2020-12) cho toàn bộ hợp đồng `config.toml` ra stdout.
|
||||
|
||||
### `completions`
|
||||
|
||||
@ -14,9 +14,12 @@ ZeroClaw ghi log đường dẫn config đã giải quyết khi khởi động
|
||||
|
||||
- `Config loaded` với các trường: `path`, `workspace`, `source`, `initialized`
|
||||
|
||||
Lệnh xuất schema:
|
||||
Lệnh CLI để kiểm tra và sửa đổi cấu hình:
|
||||
|
||||
- `zeroclaw config schema` (xuất JSON Schema draft 2020-12 ra stdout)
|
||||
- `zeroclaw config show` — xuất cấu hình hiệu lực dạng JSON (ẩn secrets)
|
||||
- `zeroclaw config get <key>` — truy vấn giá trị theo đường dẫn (ví dụ: `zeroclaw config get gateway.port`)
|
||||
- `zeroclaw config set <key> <value>` — cập nhật giá trị và lưu vào `config.toml`
|
||||
- `zeroclaw config schema` — xuất JSON Schema (draft 2020-12) ra stdout
|
||||
|
||||
## Khóa chính
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ Time-bound project status snapshots for planning documentation and operations wo
|
||||
|
||||
- [../project-triage-snapshot-2026-02-18.md](../project-triage-snapshot-2026-02-18.md)
|
||||
- [../docs-audit-2026-02-24.md](../docs-audit-2026-02-24.md)
|
||||
- [m4-5-rfi-spike-2026-02-28.md](m4-5-rfi-spike-2026-02-28.md)
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
156
docs/project/m4-5-rfi-spike-2026-02-28.md
Normal file
156
docs/project/m4-5-rfi-spike-2026-02-28.md
Normal file
@ -0,0 +1,156 @@
|
||||
# M4-5 Multi-Crate Workspace RFI Spike (2026-02-28)
|
||||
|
||||
Status: RFI complete, extraction execution pending.
|
||||
Issue: [#2263](https://github.com/zeroclaw-labs/zeroclaw/issues/2263)
|
||||
Linear parent: RMN-243
|
||||
|
||||
## Scope
|
||||
|
||||
This spike is strictly no-behavior-change planning for the M4-5 workspace split.
|
||||
|
||||
Goals:
|
||||
- capture reproducible compile baseline metrics
|
||||
- define crate boundary and dependency contract
|
||||
- define CI/feature-matrix impact and rollback posture
|
||||
- define stacked PR slicing plan (XS/S/M)
|
||||
|
||||
Out of scope:
|
||||
- broad API redesign
|
||||
- feature additions bundled with structure work
|
||||
- one-shot mega-PR extraction
|
||||
|
||||
## Baseline Compile Metrics
|
||||
|
||||
### Repro command
|
||||
|
||||
```bash
|
||||
scripts/ci/m4_5_rfi_baseline.sh /tmp/zeroclaw-m4rfi-target
|
||||
```
|
||||
|
||||
### Preflight compile blockers observed on `origin/main`
|
||||
|
||||
Before timing could run cleanly, two compile blockers were found:
|
||||
|
||||
- `src/gateway/mod.rs:2176`: `run_gateway_chat_with_tools` call missing `session_id` argument
|
||||
- `src/providers/cursor.rs:233`: `ChatResponse` initializer missing `quota_metadata`
|
||||
|
||||
RFI includes minimal compile-compat fixes for these two blockers so measurements are reproducible.
|
||||
|
||||
### Measured results (Apple Silicon macOS, local workspace)
|
||||
|
||||
| Phase | real(s) | status |
|
||||
|---|---:|---|
|
||||
| A: cold `cargo check --workspace --locked` | 306.47 | pass |
|
||||
| B: cold-ish `cargo build --workspace --locked` | 219.07 | pass |
|
||||
| C: warm `cargo check --workspace --locked` | 0.84 | pass |
|
||||
| D: incremental `cargo check` after touching `src/main.rs` | 6.19 | pass |
|
||||
|
||||
Observations:
|
||||
- cold check is the dominant iteration tax
|
||||
- warm-check performance is excellent once target artifacts exist
|
||||
- incremental behavior is acceptable but sensitive to wide root-crate coupling
|
||||
|
||||
## Current Workspace Snapshot
|
||||
|
||||
Current workspace members:
|
||||
- `.` (`zeroclaw` monolith crate)
|
||||
- `crates/robot-kit`
|
||||
|
||||
Code concentration still sits in the monolith. Large hotspots include:
|
||||
- `src/config/schema.rs`
|
||||
- `src/channels/mod.rs`
|
||||
- `src/onboard/wizard.rs`
|
||||
- `src/agent/loop_.rs`
|
||||
- `src/gateway/mod.rs`
|
||||
|
||||
## Proposed Boundary Contract
|
||||
|
||||
Target crate topology for staged extraction:
|
||||
|
||||
1. `crates/zeroclaw-types`
|
||||
- shared DTOs, enums, IDs, lightweight cross-domain traits
|
||||
- no provider/channel/network dependencies
|
||||
|
||||
1. `crates/zeroclaw-core`
|
||||
- config structs + validation, provider trait contracts, routing primitives, policy helpers
|
||||
- depends on `zeroclaw-types`
|
||||
|
||||
1. `crates/zeroclaw-memory`
|
||||
- memory traits/backends + hygiene/snapshot plumbing
|
||||
- depends on `zeroclaw-types`, `zeroclaw-core` contracts only where required
|
||||
|
||||
1. `crates/zeroclaw-channels`
|
||||
- channel adapters + inbound normalization
|
||||
- depends on `zeroclaw-types`, `zeroclaw-core`, `zeroclaw-memory`
|
||||
|
||||
1. `crates/zeroclaw-api`
|
||||
- gateway/webhook/http orchestration
|
||||
- depends on `zeroclaw-types`, `zeroclaw-core`, `zeroclaw-memory`, `zeroclaw-channels`
|
||||
|
||||
1. `crates/zeroclaw-bin` (or keep root binary package name stable)
|
||||
- CLI entrypoints + wiring only
|
||||
|
||||
Dependency rules:
|
||||
- no downward imports from foundational crates into higher layers
|
||||
- channels must not depend on gateway/http crate
|
||||
- keep provider-specific SDK deps out of `zeroclaw-types`
|
||||
- maintain feature-flag parity at workspace root during migration
|
||||
|
||||
## CI / Feature-Matrix Impact
|
||||
|
||||
Required CI adjustments during migration:
|
||||
- add workspace compile lane (`cargo check --workspace --locked`)
|
||||
- add package-focused lanes for extracted crates (`-p zeroclaw-types`, `-p zeroclaw-core`, etc.)
|
||||
- keep existing runtime behavior lanes (`test`, `sec-audit`, `codeql`) unchanged until final convergence
|
||||
- update path filters so crate-local changes trigger only relevant crate tests plus contract smoke tests
|
||||
|
||||
Guardrails:
|
||||
- changed-line strict-delta lint remains mandatory
|
||||
- each extraction PR must include no-behavior-change assertion in PR body
|
||||
- each step must include explicit rollback note
|
||||
|
||||
## Rollback Strategy
|
||||
|
||||
Per-step rollback (stack-safe):
|
||||
1. revert latest extraction PR only
|
||||
2. re-run workspace compile + existing CI matrix
|
||||
3. keep binary entrypoint and config contract untouched until final extraction stage
|
||||
|
||||
Abort criteria:
|
||||
- unexpected runtime behavior drift
|
||||
- CI lane expansion causes recurring queue stalls without signal gain
|
||||
- feature-flag compatibility regressions
|
||||
|
||||
## Stacked PR Slicing Plan
|
||||
|
||||
### PR-1 (XS)
|
||||
|
||||
- add crate shells + workspace wiring (`types/core`), no symbol moves
|
||||
- objective: establish scaffolding and CI package lanes
|
||||
|
||||
### PR-2 (S)
|
||||
|
||||
- extract low-churn shared types into `zeroclaw-types`
|
||||
- add re-export shim layer to preserve existing import paths
|
||||
|
||||
### PR-3 (S)
|
||||
|
||||
- extract config/provider contracts into `zeroclaw-core`
|
||||
- keep runtime call sites unchanged via compatibility re-exports
|
||||
|
||||
### PR-4 (M)
|
||||
|
||||
- extract memory subsystem crate and move wiring boundaries
|
||||
- run full memory + gateway regression suite
|
||||
|
||||
### PR-5 (M)
|
||||
|
||||
- extract channels/api orchestration seams
|
||||
- finalize package ownership and remove temporary re-export shims
|
||||
|
||||
## Next Execution Step
|
||||
|
||||
Open first no-behavior-change extraction PR from this RFI baseline:
|
||||
- scope: workspace crate scaffolding + CI package lanes only
|
||||
- no runtime behavior changes
|
||||
- explicit rollback command included in PR body
|
||||
@ -57,6 +57,7 @@ credential is not reused for fallback providers.
|
||||
| `perplexity` | — | No | `PERPLEXITY_API_KEY` |
|
||||
| `cohere` | — | No | `COHERE_API_KEY` |
|
||||
| `copilot` | `github-copilot` | No | (use config/`API_KEY` fallback with GitHub token) |
|
||||
| `cursor` | — | Yes | (none; Cursor manages its own credentials) |
|
||||
| `lmstudio` | `lm-studio` | Yes | (optional; local by default) |
|
||||
| `llamacpp` | `llama.cpp` | Yes | `LLAMACPP_API_KEY` (optional; only if server auth is enabled) |
|
||||
| `sglang` | — | Yes | `SGLANG_API_KEY` (optional) |
|
||||
@ -64,6 +65,16 @@ credential is not reused for fallback providers.
|
||||
| `osaurus` | — | Yes | `OSAURUS_API_KEY` (optional; defaults to `"osaurus"`) |
|
||||
| `nvidia` | `nvidia-nim`, `build.nvidia.com` | No | `NVIDIA_API_KEY` |
|
||||
|
||||
### Cursor (Headless CLI) Notes
|
||||
|
||||
- Provider ID: `cursor`
|
||||
- Invocation: `cursor --headless [--model <model>] -` (prompt is sent via stdin)
|
||||
- The `cursor` binary must be in `PATH`, or override its location with `CURSOR_PATH` env var.
|
||||
- Authentication is managed by Cursor itself (its own credential store); no API key is required.
|
||||
- The model argument is forwarded to cursor as-is; use `"default"` (or leave model empty) to let Cursor select the model.
|
||||
- This provider spawns a subprocess per request and is best suited for batch/script usage rather than high-throughput inference.
|
||||
- **Limitations**: Only the system prompt (if any) and the last user message are forwarded per request. Full multi-turn conversation history is not preserved because the headless CLI accepts a single prompt per invocation. Temperature control is not supported; non-default values return an explicit error.
|
||||
|
||||
### Vercel AI Gateway Notes
|
||||
|
||||
- Provider ID: `vercel` (alias: `vercel-ai`)
|
||||
|
||||
568
docs/rfc/001-aww-agent-wide-web.md
Normal file
568
docs/rfc/001-aww-agent-wide-web.md
Normal file
@ -0,0 +1,568 @@
|
||||
# RFC 001: AWW (Agent Wide Web) — A World Wide Web for AI Agent Experiences
|
||||
|
||||
| Status | Type | Created |
|
||||
|--------|------|---------|
|
||||
| Draft | Standards Track | 2025-02-28 |
|
||||
|
||||
## Overview
|
||||
|
||||
**AWW (Agent Wide Web)** is a decentralized experience exchange network that enables AI Agents to autonomously:
|
||||
|
||||
- **Publish Experiences** — Create "experience pages" when encountering problems
|
||||
- **Discover Experiences** — Search for relevant experiences from other agents
|
||||
- **Link Experiences** — Establish connections between related experiences
|
||||
- **Verify Experiences** — Endorse and rate experience quality
|
||||
|
||||
> A tribute to Tim Berners-Lee and the World Wide Web: WWW connected documents, AWW connects experiences.
|
||||
|
||||
---
|
||||
|
||||
## Motivation
|
||||
|
||||
### Historical Analogy
|
||||
|
||||
```
|
||||
Before 1990: Information Silos → After 1990: World Wide Web
|
||||
- Each organization had own systems - Unified protocol (HTTP)
|
||||
- No cross-organizational access - Anyone can publish/access
|
||||
- Constant reinvention - Explosive growth
|
||||
|
||||
Now: Agent Experience Silos → Future: Agent Wide Web
|
||||
- Each agent learns independently - Unified experience protocol (AWP)
|
||||
- No sharing of failures/successes - Any agent can publish/access
|
||||
- Repeated trial and error - Exponential collective intelligence
|
||||
```
|
||||
|
||||
### Problem Statement
|
||||
|
||||
1. **Experience Cannot Be Reused** — Agent A solves a problem, Agent B rediscovers it
|
||||
2. **No Wisdom Accumulation** — Agent populations lack "long-term memory"
|
||||
3. **No Collaborative Evolution** — No mechanism for agent populations to become smarter
|
||||
|
||||
### Vision
|
||||
|
||||
```
|
||||
In ten years:
|
||||
- New agents connect to AWW as their first action
|
||||
- When encountering problems: query relevant experiences (like humans using Google)
|
||||
- After solving: publish experiences to contribute to collective intelligence
|
||||
- Every agent stands on the shoulders of the entire network
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Core Design
|
||||
|
||||
### 1. Experience Page — Analogous to HTML
|
||||
|
||||
```json
|
||||
{
|
||||
"aww_url": "aww:///rust/async/arc-pattern-1234",
|
||||
"metadata": {
|
||||
"author": "did:agent:abc123",
|
||||
"created_at": "2025-02-28T10:00:00Z",
|
||||
"updated_at": "2025-02-28T12:00:00Z",
|
||||
"version": "1.0"
|
||||
},
|
||||
"content": {
|
||||
"title": "Solving Rust Async Race Conditions with Arc",
|
||||
"problem": {
|
||||
"description": "Multi-threaded access to shared state causing data race",
|
||||
"tags": ["rust", "async", "concurrency", "data-race"],
|
||||
"context": {
|
||||
"env": "tokio",
|
||||
"rust_version": "1.75",
|
||||
"os": "linux"
|
||||
}
|
||||
},
|
||||
"solution": {
|
||||
"code": "use std::sync::Arc;\nuse tokio::task::spawn;",
|
||||
"explanation": "Using Arc for shared ownership across async tasks",
|
||||
"alternative_approaches": [
|
||||
"Rc<T> in single-threaded contexts",
|
||||
"Channels for message passing"
|
||||
]
|
||||
},
|
||||
"outcome": {
|
||||
"result": "success",
|
||||
"metrics": {
|
||||
"fix_time": "2h",
|
||||
"prevention_of_regressions": true
|
||||
},
|
||||
"side_effects": "5% memory overhead increase"
|
||||
},
|
||||
"references": [
|
||||
"aww:///rust/patterns/cloning-vs-arc-5678",
|
||||
"https://doc.rust.org/std/sync/struct.Arc.html"
|
||||
]
|
||||
},
|
||||
"social": {
|
||||
"endorsements": ["did:agent:def456", "did:agent:ghi789"],
|
||||
"reputation_score": 0.95,
|
||||
"usage_count": 1247,
|
||||
"linked_from": ["aww:///rust/troubleshooting/panic-9999"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. AWP Protocol (Agent Web Protocol) — Analogous to HTTP
|
||||
|
||||
| Operation | Method | Description | Request Body | Response |
|
||||
|-----------|--------|-------------|--------------|----------|
|
||||
| Get Experience | `GET /experience/{url}` | Fetch by URL | N/A | Experience |
|
||||
| Publish | `POST /experience` | Publish new | Experience | URL |
|
||||
| Search | `SEARCH /experiences` | Vector search | SearchQuery | Experience[] |
|
||||
| Link | `LINK /experience/{url}` | Create links | LinkTarget | Success |
|
||||
| Endorse | `ENDORSE /experience/{url}` | Add endorsement | Endorsement | Success |
|
||||
| Update | `PATCH /experience/{url}` | Update content | PartialExp | Success |
|
||||
|
||||
### 3. AWW URL Format
|
||||
|
||||
Format: `aww://{category}/{subcategory}/{slug}-{id}`
|
||||
|
||||
Examples:
|
||||
- `aww:///rust/async/arc-pattern-1234`
|
||||
- `aww:///python/ml/tensorflow-gpu-leak-5678`
|
||||
- `aww:///devops/k8s/pod-crash-loop-9012`
|
||||
- `aww:///agents/coordination/task-delegation-4321`
|
||||
|
||||
### 4. Identity & Authentication
|
||||
|
||||
**DID (Decentralized Identifier) Format:**
|
||||
```
|
||||
did:agent:{method}:{id}
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `did:agent:z:6MkqLqY4...` (ZeroClaw agent)
|
||||
- `did:agent:eth:0x123...` (Ethereum-based)
|
||||
- `did:agent:web:example.com...` (web-based)
|
||||
|
||||
---
|
||||
|
||||
## ZeroClaw Integration
|
||||
|
||||
### Rust API Design
|
||||
|
||||
```rust
|
||||
/// AWW Client for interacting with the Agent Wide Web
|
||||
pub struct AwwClient {
|
||||
base_url: String,
|
||||
agent_id: Did,
|
||||
auth: Option<AuthProvider>,
|
||||
}
|
||||
|
||||
impl AwwClient {
|
||||
/// Publish experience to Agent Wide Web
|
||||
pub async fn publish_experience(&self, exp: Experience) -> Result<AwwUrl>;
|
||||
|
||||
/// Search relevant experiences by vector similarity
|
||||
pub async fn search_experiences(&self, query: ExperienceQuery)
|
||||
-> Result<Vec<Experience>>;
|
||||
|
||||
/// Get specific experience by URL
|
||||
pub async fn get_experience(&self, url: &AwwUrl) -> Result<Experience>;
|
||||
|
||||
/// Endorse an experience
|
||||
pub async fn endorse(&self, url: &AwwUrl, endorsement: Endorsement)
|
||||
-> Result<()>;
|
||||
|
||||
/// Link two related experiences
|
||||
pub async fn link_experiences(&self, from: &AwwUrl, to: &AwwUrl)
|
||||
-> Result<()>;
|
||||
}
|
||||
|
||||
/// Extend Memory trait to support AWW synchronization
|
||||
#[async_trait]
|
||||
pub trait AwwMemory: Memory {
|
||||
/// Sync local experiences to AWW
|
||||
async fn sync_to_aww(&self, client: &AwwClient) -> Result<()>;
|
||||
|
||||
/// Query AWW for relevant experiences
|
||||
async fn query_aww(&self, client: &AwwClient, query: &str)
|
||||
-> Result<Vec<Experience>>;
|
||||
|
||||
/// Auto-publish new experiences
|
||||
async fn auto_publish(&self, client: &AwwClient, trigger: PublishTrigger)
|
||||
-> Result<()>;
|
||||
}
|
||||
|
||||
/// Agent can automatically use AWW
|
||||
impl Agent {
|
||||
pub async fn solve_with_aww(&mut self, problem: &Problem) -> Result<Solution> {
|
||||
// 1. First check Agent Wide Web
|
||||
let experiences = self.aww_client
|
||||
.search_experiences(ExperienceQuery::from_problem(problem))
|
||||
.await?;
|
||||
|
||||
if let Some(exp) = experiences.first() {
|
||||
// 2. Found relevant experience, try to apply
|
||||
match self.apply_solution(&exp.solution).await {
|
||||
Ok(solution) => {
|
||||
// Endorse the helpful experience
|
||||
let _ = self.aww_client.endorse(&exp.aww_url, Endorsement::success()).await;
|
||||
return Ok(solution);
|
||||
}
|
||||
Err(e) => {
|
||||
// Report if experience didn't work
|
||||
let _ = self.aww_client.endorse(&exp.aww_url, Endorsement::failure(&e)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Not found or failed, solve yourself then publish
|
||||
let solution = self.solve_myself(problem).await?;
|
||||
let experience = Experience::from_problem_and_solution(problem, &solution);
|
||||
|
||||
self.aww_client.publish_experience(experience).await?;
|
||||
Ok(solution)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Add to `config/schema.rs`:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AwwConfig {
|
||||
/// AWW endpoint URL
|
||||
pub endpoint: String,
|
||||
|
||||
/// Enable auto-publishing of experiences
|
||||
pub auto_publish: bool,
|
||||
|
||||
/// Publish trigger conditions
|
||||
pub publish_trigger: PublishTrigger,
|
||||
|
||||
/// Enable auto-querying for solutions
|
||||
pub auto_query: bool,
|
||||
|
||||
/// Agent identity (DID)
|
||||
pub agent_did: Option<Did>,
|
||||
|
||||
/// Authentication credentials
|
||||
pub auth: Option<AuthConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PublishTrigger {
|
||||
/// Publish after every successful solution
|
||||
OnSuccess,
|
||||
|
||||
/// Publish after every failure
|
||||
OnFailure,
|
||||
|
||||
/// Publish both success and failure
|
||||
Always,
|
||||
|
||||
/// Publish only when explicitly requested
|
||||
Manual,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Network Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Agent Wide Web │
|
||||
│ (AWW) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ ZeroClaw │ │ LangChain │ │ AutoGPT │ │
|
||||
│ │ Agent A │ │ Agent B │ │ Agent C │ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────┼────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ AWP Protocol │ │
|
||||
│ │ (Agent Web │ │
|
||||
│ │ Protocol) │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┼────────────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │
|
||||
│ │ Nodes │ │ Nodes │ │ Nodes │ │
|
||||
│ │ (ZeroClaw│ │ (Python │ │ (Go │ │
|
||||
│ │ Hosts) │ │ Hosts) │ │ Hosts) │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────────┼────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ Distributed │ │
|
||||
│ │ Experience DB │ │
|
||||
│ │ (IPFS/S3/Custom) │ │
|
||||
│ └────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Features:
|
||||
- Decentralized: Any organization can run nodes
|
||||
- Interoperable: Cross-framework, cross-language
|
||||
- Scalable: Horizontal scaling of storage and compute
|
||||
- Censorship-resistant: Distributed storage, no single point of failure
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phased Roadmap
|
||||
|
||||
### Phase 1: Protocol Definition (1-2 months)
|
||||
|
||||
- [ ] AWP protocol specification document
|
||||
- [ ] AWW URL format standard
|
||||
- [ ] Experience Schema v1.0
|
||||
- [ ] RESTful API specification
|
||||
- [ ] Security and authentication spec
|
||||
|
||||
### Phase 2: ZeroClaw Implementation (2-3 months)
|
||||
|
||||
- [ ] `aww-client` crate creation
|
||||
- [ ] Extend `memory` module to support AWW
|
||||
- [ ] Extend `coordination` module to support AWW messages
|
||||
- [ ] Configuration schema updates
|
||||
- [ ] Example: auto-publish/query agent
|
||||
- [ ] Unit tests and integration tests
|
||||
|
||||
### Phase 3: Infrastructure (3-4 months)
|
||||
|
||||
- [ ] AWW node implementation (Rust)
|
||||
- [ ] Distributed storage backend (IPFS integration)
|
||||
- [ ] Vector search engine (embedding-based)
|
||||
- [ ] Reputation system MVP
|
||||
- [ ] Basic web UI for human viewing
|
||||
|
||||
### Phase 4: Ecosystem (ongoing)
|
||||
|
||||
- [ ] Multi-language SDKs (Python, Go, TypeScript)
|
||||
- [ ] Advanced monitoring dashboard
|
||||
- [ ] Agent registry and discovery
|
||||
- [ ] Analytics and usage metrics
|
||||
|
||||
### Phase 5: Decentralization (future)
|
||||
|
||||
- [ ] Blockchain-based URL ownership (optional)
|
||||
- [ ] DAO governance mechanism
|
||||
- [ ] Economic incentives (token-based, optional)
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Decentralized vs Centralized
|
||||
|
||||
#### Decision: Hybrid Model
|
||||
|
||||
- **Bootstrapping phase**: Single centralized node operated by maintainers
|
||||
- **Growth phase**: Multiple trusted nodes
|
||||
- **Maturity phase**: Full decentralization with open participation
|
||||
|
||||
**Rationale**: Balances early usability with long-term resilience
|
||||
|
||||
### 2. Identity & Authentication
|
||||
|
||||
#### Decision: DID (Decentralized Identifier)
|
||||
|
||||
```rust
|
||||
pub enum Did {
|
||||
ZeroClaw(String),
|
||||
Ethereum(Address),
|
||||
Web(String),
|
||||
Custom(String),
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale**: Framework-agnostic, future-proof
|
||||
|
||||
### 3. Storage Layer
|
||||
|
||||
#### Decision: Tiered Storage
|
||||
|
||||
| Tier | Technology | Use Case |
|
||||
|------|------------|----------|
|
||||
| Hot | Redis/PostgreSQL | Frequent access, low latency |
|
||||
| Warm | S3/Object Storage | General purpose |
|
||||
| Cold | IPFS/Filecoin | Archival, decentralization |
|
||||
|
||||
**Rationale**: Cost-effective, scalable
|
||||
|
||||
### 4. Search Engine
|
||||
|
||||
#### Decision: Hybrid Search
|
||||
|
||||
- **Vector similarity**: Semantic understanding
|
||||
- **Keyword BM25**: Exact match
|
||||
- **Graph traversal**: Related experience discovery
|
||||
|
||||
**Rationale**: Precision + recall optimization
|
||||
|
||||
### 5. Quality Assurance
|
||||
|
||||
#### Decision: Multi-dimensional
|
||||
|
||||
- **Execution verification**: For reproducible experiences
|
||||
- **Community endorsement**: Reputation-based
|
||||
- **Usage statistics**: Real-world validation
|
||||
- **Human moderation**: Early-stage quality control
|
||||
|
||||
**Rationale**: Defense in depth
|
||||
|
||||
---
|
||||
|
||||
## Relationship with Existing Projects
|
||||
|
||||
| Project | Relationship | Integration Path |
|
||||
|---------|--------------|------------------|
|
||||
| **MCP** | Complementary | MCP connects tools, AWW connects experiences |
|
||||
| **A2A** | Complementary | A2A for real-time communication, AWW for persistence |
|
||||
| **SAMEP** | Reference | Borrow security model, more open design |
|
||||
| **ZeroClaw** | Parent | First full implementation |
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Trust & Verification
|
||||
|
||||
- How to prevent low-quality or malicious experiences?
|
||||
- Should we require execution verification for code solutions?
|
||||
- What should the reputation system look like?
|
||||
|
||||
### Privacy & Security
|
||||
|
||||
- How to protect sensitive/corporate experiences?
|
||||
- Should we support encrypted storage?
|
||||
- How to implement access control lists?
|
||||
|
||||
### Incentives
|
||||
|
||||
- Why would agents share experiences?
|
||||
- Reciprocity? Reputation points? Economic tokens?
|
||||
- Should we implement a "credit" system?
|
||||
|
||||
### Scalability
|
||||
|
||||
- How to handle millions of experiences?
|
||||
- Should we shard by category/time/popularity?
|
||||
- How to handle hot partitions?
|
||||
|
||||
### Governance
|
||||
|
||||
- Who decides protocol evolution?
|
||||
- Foundation-based? DAO? Community consensus?
|
||||
- How to handle forks?
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Malicious Experience Injection**
|
||||
- Code signing and verification
|
||||
- Sandboxed execution environments
|
||||
- Community reporting mechanisms
|
||||
|
||||
2. **Data Privacy**
|
||||
- Sensitive data redaction
|
||||
- Access control for corporate experiences
|
||||
- GDPR/compliance considerations
|
||||
|
||||
3. **Denial of Service**
|
||||
- Rate limiting per agent
|
||||
- CAPTCHA alternatives for agent verification
|
||||
- Distributed denial mitigation
|
||||
|
||||
4. **Supply Chain Attacks**
|
||||
- Dependency verification for referenced experiences
|
||||
- Immutable storage for published experiences
|
||||
- Audit trail for all modifications
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Tim Berners-Lee's original WWW proposal](http://www.w3.org/History/1989/proposal.html)
|
||||
- [A2A Protocol (Google)](https://github.com/google/A2A)
|
||||
- [MCP (Anthropic)](https://modelcontextprotocol.io/)
|
||||
- [SAMEP: Secure Agent Memory Exchange Protocol](https://arxiv.org/abs/2507.10562)
|
||||
- [IPFS Design Overview](https://docs.ipfs.tech/concepts/how-ipfs-works/)
|
||||
- [DID Core Specification](https://www.w3.org/TR/did-core/)
|
||||
|
||||
---
|
||||
|
||||
## Vision Statement
|
||||
|
||||
> "We believe the future of AI is not isolated superintelligence, but interconnected intelligence networks.
|
||||
>
|
||||
> Just as the WWW globalized human knowledge, AWW will globalize agent experiences.
|
||||
>
|
||||
> Every agent can build upon the experiences of the entire network, rather than reinventing the wheel.
|
||||
>
|
||||
> This is a decentralized, open, self-evolving knowledge ecosystem."
|
||||
|
||||
### Ten-Year Vision
|
||||
|
||||
| Year | Milestone |
|
||||
|------|-----------|
|
||||
| 2025 | Protocol finalized + MVP |
|
||||
| 2026 | First public node launches |
|
||||
| 2027 | 100K+ experiences shared |
|
||||
| 2028 | Cross-framework ecosystem |
|
||||
| 2030 | Default knowledge source for agents |
|
||||
| 2035 | Collective intelligence surpasses individual agents |
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Glossary
|
||||
|
||||
- **AWW**: Agent Wide Web
|
||||
- **AWP**: Agent Web Protocol
|
||||
- **DID**: Decentralized Identifier
|
||||
- **Experience**: A structured record of problem-solution-outcome
|
||||
- **Endorsement**: A quality vote on an experience
|
||||
- **URE**: Uniform Resource Identifier for Experiences (AWW URL)
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Example Use Cases
|
||||
|
||||
### Use Case 1: Debugging Assistant
|
||||
|
||||
```
|
||||
1. Agent encounters panic in Rust async code
|
||||
2. Query AWW: "rust async panic arc mutex"
|
||||
3. Find relevant experience with Arc<Mutex<T>> pattern
|
||||
4. Apply solution, resolve issue in 10 minutes
|
||||
5. Endorse experience as helpful
|
||||
```
|
||||
|
||||
### Use Case 2: Configuration Discovery
|
||||
|
||||
```
|
||||
1. Agent needs to configure Kubernetes HPA
|
||||
2. Query AWW: "kubernetes hpa cpu metric"
|
||||
3. Find experience with working metrics-server setup
|
||||
4. Apply configuration, verify
|
||||
5. Publish variation for different cloud provider
|
||||
```
|
||||
|
||||
### Use Case 3: Cross-Project Learning
|
||||
|
||||
```
|
||||
1. ZeroClaw agent solves database connection pooling issue
|
||||
2. Publishes experience to AWW
|
||||
3. LangChain agent encounters similar issue
|
||||
4. Finds ZeroClaw's experience
|
||||
5. Adapts solution to Python context
|
||||
6. Links both experiences for future reference
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Copyright**: CC-BY-4.0
|
||||
@ -20,6 +20,7 @@ For current runtime behavior, start here:
|
||||
- CI/Security audit event schema: [../audit-event-schema.md](../audit-event-schema.md)
|
||||
- Syscall anomaly detection: [./syscall-anomaly-detection.md](./syscall-anomaly-detection.md)
|
||||
- Perplexity suffix filter: [./perplexity-filter.md](./perplexity-filter.md)
|
||||
- Enject-inspired hardening notes: [./enject-inspired-hardening.md](./enject-inspired-hardening.md)
|
||||
|
||||
## Proposal / Roadmap Docs
|
||||
|
||||
|
||||
186
docs/security/enject-inspired-hardening.md
Normal file
186
docs/security/enject-inspired-hardening.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Enject-Inspired Hardening Notes
|
||||
|
||||
Date: 2026-02-28
|
||||
|
||||
## Scope
|
||||
|
||||
This document records a focused security review of `GreatScott/enject` and maps the useful controls to ZeroClaw runtime/tooling.
|
||||
|
||||
The goal is not feature parity with `enject` (a dedicated secret-injection CLI), but to import practical guardrail patterns for agent safety and operational reliability.
|
||||
|
||||
## Key Enject Security Patterns
|
||||
|
||||
From `enject` architecture and source review:
|
||||
|
||||
1. Secrets should not be plaintext in project files.
|
||||
2. Runtime should fail closed on unresolved secret references.
|
||||
3. Secret entry should avoid shell history and process-argument exposure.
|
||||
4. Sensitive material should be zeroized or lifetime-minimized in memory.
|
||||
5. Encryption/writes should be authenticated and atomic.
|
||||
6. Tooling should avoid convenience features that become exfiltration channels (for example, no `get`/`export`).
|
||||
|
||||
## Applied to ZeroClaw
|
||||
|
||||
### 1) Sensitive file access policy was centralized
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/security/sensitive_paths.rs`
|
||||
- `src/tools/file_read.rs`
|
||||
- `src/tools/file_write.rs`
|
||||
- `src/tools/file_edit.rs`
|
||||
|
||||
Added shared sensitive-path detection for:
|
||||
|
||||
- exact names (`.env`, `.envrc`, `.git-credentials`, key filenames)
|
||||
- suffixes (`.pem`, `.key`, `.p12`, `.pfx`, `.ovpn`, `.kubeconfig`, `.netrc`)
|
||||
- sensitive path components (`.ssh`, `.aws`, `.gnupg`, `.kube`, `.docker`, `.azure`, `.secrets`)
|
||||
|
||||
Rationale: a single classifier avoids drift between tools and keeps enforcement consistent as more tools are hardened.
|
||||
|
||||
### 2) Sensitive file reads are blocked by default in `file_read`
|
||||
|
||||
Implemented in `src/tools/file_read.rs`:
|
||||
|
||||
- Enforced block both:
|
||||
- before canonicalization (input path)
|
||||
- after canonicalization (resolved path, including symlink targets)
|
||||
- Added explicit opt-in gate:
|
||||
- `autonomy.allow_sensitive_file_reads = true`
|
||||
|
||||
Rationale: This mirrors `enject`'s "plaintext secret files are high-risk by default" stance while preserving operator override for controlled break-glass scenarios.
|
||||
|
||||
### 3) Sensitive file writes/edits are blocked by default in `file_write` + `file_edit`
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/tools/file_write.rs`
|
||||
- `src/tools/file_edit.rs`
|
||||
|
||||
Enforced block both:
|
||||
|
||||
- before canonicalization (input path)
|
||||
- after canonicalization (resolved path, including symlink targets)
|
||||
|
||||
Added explicit opt-in gate:
|
||||
|
||||
- `autonomy.allow_sensitive_file_writes = true`
|
||||
|
||||
Rationale: unlike read-only exposure, write/edit to secret-bearing files can silently corrupt credentials, rotate values unintentionally, or create exfiltration artifacts in VCS/workspace state.
|
||||
|
||||
### 4) Hard-link escape guard for file tools
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/security/file_link_guard.rs`
|
||||
- `src/tools/file_read.rs`
|
||||
- `src/tools/file_write.rs`
|
||||
- `src/tools/file_edit.rs`
|
||||
|
||||
Behavior:
|
||||
|
||||
- All three file tools refuse existing files with link-count > 1.
|
||||
- This blocks a class of path-based bypasses where a workspace file name is hard-linked to external sensitive content.
|
||||
|
||||
Rationale: canonicalization and symlink checks do not reveal hard-link provenance; link-count guard is a conservative fail-closed protection with low operational impact.
|
||||
|
||||
### 5) Config-level gates for sensitive reads/writes
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/config/schema.rs`
|
||||
- `src/security/policy.rs`
|
||||
- `docs/config-reference.md`
|
||||
|
||||
Added:
|
||||
|
||||
- `autonomy.allow_sensitive_file_reads` (default: `false`)
|
||||
- `autonomy.allow_sensitive_file_writes` (default: `false`)
|
||||
|
||||
Both are mapped into runtime `SecurityPolicy`.
|
||||
|
||||
### 6) Pushover credential ingestion hardening
|
||||
|
||||
Implemented in `src/tools/pushover.rs`:
|
||||
|
||||
- Environment-first credential source (`PUSHOVER_TOKEN`, `PUSHOVER_USER_KEY`)
|
||||
- `.env` fallback retained for compatibility
|
||||
- Hard error when only one env variable is set (partial state)
|
||||
- Hard error when `.env` values are unresolved `en://` / `ev://` references
|
||||
- Test env mutation isolation via `EnvGuard` + global lock
|
||||
|
||||
Rationale: This aligns with `enject`'s fail-closed treatment of unresolved secret references and reduces accidental plaintext handling ambiguity.
|
||||
|
||||
### 7) Non-CLI approval session grant now actually bypasses prompt
|
||||
|
||||
Implemented in `src/agent/loop_.rs`:
|
||||
|
||||
- `run_tool_call_loop` now honors `ApprovalManager::is_non_cli_session_granted(tool)`.
|
||||
- Added runtime trace event: `approval_bypass_non_cli_session_grant`.
|
||||
- Added regression test:
|
||||
- `run_tool_call_loop_uses_non_cli_session_grant_without_waiting_for_prompt`
|
||||
|
||||
Rationale: This fixes a reliability/safety gap where already-approved non-CLI tools could still stall on pending approval waits.
|
||||
|
||||
### 8) Outbound leak guard strict mode + config parity across delivery paths
|
||||
|
||||
Implemented in:
|
||||
|
||||
- `src/config/schema.rs`
|
||||
- `src/channels/mod.rs`
|
||||
- `src/gateway/mod.rs`
|
||||
- `src/gateway/ws.rs`
|
||||
- `src/gateway/openai_compat.rs`
|
||||
|
||||
Added outbound leak policy:
|
||||
|
||||
- `security.outbound_leak_guard.enabled` (default: `true`)
|
||||
- `security.outbound_leak_guard.action` (`redact` or `block`, default: `redact`)
|
||||
- `security.outbound_leak_guard.sensitivity` (`0.0..=1.0`, default: `0.7`)
|
||||
|
||||
Behavior:
|
||||
|
||||
- `redact`: preserve current behavior, redact detected credential material and deliver response.
|
||||
- `block`: suppress original response when leak detector matches and return safe fallback text.
|
||||
- Gateway and WebSocket now read runtime config for this policy rather than hard-coded defaults.
|
||||
- OpenAI-compatible `/v1/chat/completions` path now uses the same leak guard for both non-streaming and streaming responses.
|
||||
- For streaming, when guard is enabled, output is buffered and sanitized before SSE emission so raw deltas are not leaked pre-scan.
|
||||
|
||||
Rationale: this closes a consistency gap where strict outbound controls could be applied in channels but silently downgraded at gateway/ws boundaries.
|
||||
|
||||
## Validation Evidence
|
||||
|
||||
Targeted and full-library tests passed after hardening:
|
||||
|
||||
- `tools::file_write::tests::file_write_blocks_sensitive_file_by_default`
|
||||
- `tools::file_write::tests::file_write_allows_sensitive_file_when_configured`
|
||||
- `tools::file_edit::tests::file_edit_blocks_sensitive_file_by_default`
|
||||
- `tools::file_edit::tests::file_edit_allows_sensitive_file_when_configured`
|
||||
- `tools::file_read::tests::file_read_blocks_hardlink_escape`
|
||||
- `tools::file_write::tests::file_write_blocks_hardlink_target_file`
|
||||
- `tools::file_edit::tests::file_edit_blocks_hardlink_target_file`
|
||||
- `channels::tests::process_channel_message_executes_tool_calls_instead_of_sending_raw_json`
|
||||
- `channels::tests::process_channel_message_telegram_does_not_persist_tool_summary_prefix`
|
||||
- `channels::tests::process_channel_message_streaming_hides_internal_progress_by_default`
|
||||
- `channels::tests::process_channel_message_streaming_shows_internal_progress_on_explicit_request`
|
||||
- `channels::tests::process_channel_message_executes_tool_calls_with_alias_tags`
|
||||
- `channels::tests::process_channel_message_respects_configured_max_tool_iterations_above_default`
|
||||
- `channels::tests::process_channel_message_reports_configured_max_tool_iterations_limit`
|
||||
- `agent::loop_::tests::run_tool_call_loop_uses_non_cli_session_grant_without_waiting_for_prompt`
|
||||
- `channels::tests::sanitize_channel_response_blocks_detected_credentials_when_configured`
|
||||
- `gateway::mod::tests::sanitize_gateway_response_blocks_detected_credentials_when_configured`
|
||||
- `gateway::ws::tests::sanitize_ws_response_blocks_detected_credentials_when_configured`
|
||||
- `cargo test -q --lib` => passed (`3760 passed; 0 failed; 4 ignored`)
|
||||
|
||||
## Residual Risks and Next Hardening Steps
|
||||
|
||||
1. Runtime exfiltration remains possible if a model is induced to print secrets from tool output.
|
||||
2. Secrets in child-process environment remain readable to processes with equivalent host privileges.
|
||||
3. Some tool paths outside `file_read` may still accept high-sensitivity material without uniform policy checks.
|
||||
|
||||
Recommended follow-up work:
|
||||
|
||||
1. Centralize a shared `SensitiveInputPolicy` used by all secret-adjacent tools (not just `file_read`).
|
||||
2. Introduce a typed secret wrapper for tool credential flows to reduce `String` lifetime and accidental logging.
|
||||
3. Extend leak-guard policy parity checks to any future outbound surfaces beyond channel/gateway/ws.
|
||||
4. Add e2e tests covering "unresolved secret reference" behavior across all credential-consuming tools.
|
||||
@ -67,6 +67,9 @@ in [section 2](#32-protocol-stdin--stdout).
|
||||
| `wasmtime` CLI | Local testing (`zeroclaw skill test`) |
|
||||
| Language-specific toolchain | Building `.wasm` from source |
|
||||
|
||||
> Note: Android/Termux builds currently run in stub mode for `wasm-tools`.
|
||||
> Build on Linux/macOS/Windows for full WASM runtime support.
|
||||
|
||||
Install `wasmtime` CLI:
|
||||
|
||||
```bash
|
||||
|
||||
519
scripts/android/termux_source_build_check.sh
Executable file
519
scripts/android/termux_source_build_check.sh
Executable file
@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
TARGET="aarch64-linux-android"
|
||||
RUN_CARGO_CHECK=0
|
||||
MODE="auto"
|
||||
DIAGNOSE_LOG=""
|
||||
JSON_OUTPUT=""
|
||||
QUIET=0
|
||||
STRICT=0
|
||||
ERROR_MESSAGE=""
|
||||
ERROR_CODE="NONE"
|
||||
config_linker=""
|
||||
cargo_linker_override=""
|
||||
cc_linker_override=""
|
||||
effective_linker=""
|
||||
|
||||
WARNINGS=()
|
||||
SUGGESTIONS=()
|
||||
DETECTIONS=()
|
||||
DETECTION_CODES=()
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/android/termux_source_build_check.sh [--target <triple>] [--mode <auto|termux-native|ndk-cross>] [--run-cargo-check] [--diagnose-log <path>] [--json-output <path|-] [--quiet] [--strict]
|
||||
|
||||
Options:
|
||||
--target <triple> Android Rust target (default: aarch64-linux-android)
|
||||
Supported: aarch64-linux-android, armv7-linux-androideabi
|
||||
--mode <mode> Validation mode:
|
||||
auto (default): infer from environment
|
||||
termux-native: expect plain clang + no cross overrides
|
||||
ndk-cross: expect NDK wrapper linker + matching CC_*
|
||||
--run-cargo-check Run cargo check --locked --target <triple> --no-default-features
|
||||
--diagnose-log <p> Diagnose an existing cargo error log and print targeted recovery commands.
|
||||
--json-output <p|-] Write machine-readable report JSON to path, or '-' for stdout.
|
||||
--quiet Suppress informational logs (warnings/errors still emitted).
|
||||
--strict Fail with structured error when any warning is detected.
|
||||
-h, --help Show this help
|
||||
|
||||
Purpose:
|
||||
Validate Android source-build environment for ZeroClaw, with focus on:
|
||||
- Termux native builds using plain clang
|
||||
- NDK cross-build overrides (CARGO_TARGET_*_LINKER and CC_*)
|
||||
- Common cc-rs linker mismatch failures
|
||||
EOF
|
||||
}
|
||||
|
||||
log() {
|
||||
if [[ "$QUIET" -eq 0 ]]; then
|
||||
printf '[android-selfcheck] %s\n' "$*"
|
||||
fi
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf '[android-selfcheck] warning: %s\n' "$*" >&2
|
||||
WARNINGS+=("$*")
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\n'/\\n}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\t'/\\t}
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
json_array_from_args() {
|
||||
local first=1
|
||||
local item
|
||||
printf '['
|
||||
for item in "$@"; do
|
||||
if [[ "$first" -eq 0 ]]; then
|
||||
printf ', '
|
||||
fi
|
||||
printf '"%s"' "$(json_escape "$item")"
|
||||
first=0
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
json_string_or_null() {
|
||||
local s="${1:-}"
|
||||
if [[ -z "$s" ]]; then
|
||||
printf 'null'
|
||||
else
|
||||
printf '"%s"' "$(json_escape "$s")"
|
||||
fi
|
||||
}
|
||||
|
||||
suggest() {
|
||||
log "$*"
|
||||
SUGGESTIONS+=("$*")
|
||||
}
|
||||
|
||||
detect_warn() {
|
||||
warn "$*"
|
||||
DETECTIONS+=("$*")
|
||||
}
|
||||
|
||||
add_detection_code() {
|
||||
local code="$1"
|
||||
local existing
|
||||
for existing in "${DETECTION_CODES[@]}"; do
|
||||
if [[ "$existing" == "$code" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
DETECTION_CODES+=("$code")
|
||||
}
|
||||
|
||||
emit_json_report() {
|
||||
local exit_code="$1"
|
||||
[[ -n "$JSON_OUTPUT" ]] || return 0
|
||||
|
||||
local status_text="ok"
|
||||
if [[ "$exit_code" -ne 0 ]]; then
|
||||
status_text="error"
|
||||
fi
|
||||
|
||||
local env_text="non-termux"
|
||||
if [[ "${is_termux:-0}" -eq 1 ]]; then
|
||||
env_text="termux"
|
||||
fi
|
||||
|
||||
local ts
|
||||
ts="$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || printf '%s' "unknown")"
|
||||
|
||||
local json_payload
|
||||
json_payload="$(
|
||||
printf '{\n'
|
||||
printf ' "schema_version": "zeroclaw.android-selfcheck.v1",\n'
|
||||
printf ' "timestamp_utc": "%s",\n' "$(json_escape "$ts")"
|
||||
printf ' "status": "%s",\n' "$status_text"
|
||||
printf ' "exit_code": %s,\n' "$exit_code"
|
||||
printf ' "error_code": "%s",\n' "$(json_escape "$ERROR_CODE")"
|
||||
printf ' "error_message": %s,\n' "$(json_string_or_null "$ERROR_MESSAGE")"
|
||||
printf ' "target": "%s",\n' "$(json_escape "$TARGET")"
|
||||
printf ' "mode_requested": "%s",\n' "$(json_escape "$MODE")"
|
||||
printf ' "mode_effective": "%s",\n' "$(json_escape "${effective_mode:-}")"
|
||||
printf ' "environment": "%s",\n' "$env_text"
|
||||
printf ' "strict_mode": %s,\n' "$([[ "$STRICT" -eq 1 ]] && printf 'true' || printf 'false')"
|
||||
printf ' "run_cargo_check": %s,\n' "$([[ "$RUN_CARGO_CHECK" -eq 1 ]] && printf 'true' || printf 'false')"
|
||||
printf ' "diagnose_log": %s,\n' "$(json_string_or_null "$DIAGNOSE_LOG")"
|
||||
printf ' "config_linker": %s,\n' "$(json_string_or_null "$config_linker")"
|
||||
printf ' "cargo_linker_override": %s,\n' "$(json_string_or_null "$cargo_linker_override")"
|
||||
printf ' "cc_linker_override": %s,\n' "$(json_string_or_null "$cc_linker_override")"
|
||||
printf ' "effective_linker": %s,\n' "$(json_string_or_null "$effective_linker")"
|
||||
printf ' "warning_count": %s,\n' "${#WARNINGS[@]}"
|
||||
printf ' "detection_count": %s,\n' "${#DETECTIONS[@]}"
|
||||
printf ' "warnings": %s,\n' "$(json_array_from_args "${WARNINGS[@]}")"
|
||||
printf ' "detections": %s,\n' "$(json_array_from_args "${DETECTIONS[@]}")"
|
||||
printf ' "detection_codes": %s,\n' "$(json_array_from_args "${DETECTION_CODES[@]}")"
|
||||
printf ' "suggestions": %s\n' "$(json_array_from_args "${SUGGESTIONS[@]}")"
|
||||
printf '}\n'
|
||||
)"
|
||||
|
||||
if [[ "$JSON_OUTPUT" == "-" ]]; then
|
||||
printf '%s' "$json_payload"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$JSON_OUTPUT")"
|
||||
printf '%s' "$json_payload" >"$JSON_OUTPUT"
|
||||
}
|
||||
|
||||
die() {
|
||||
ERROR_MESSAGE="$*"
|
||||
printf '[android-selfcheck] error: %s\n' "$*" >&2
|
||||
emit_json_report 1
|
||||
exit 1
|
||||
}
|
||||
|
||||
enforce_strict_mode() {
|
||||
if [[ "$STRICT" -eq 1 && "${#WARNINGS[@]}" -gt 0 ]]; then
|
||||
ERROR_CODE="STRICT_WARNINGS"
|
||||
die "strict mode failed: ${#WARNINGS[@]} warning(s) detected"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--target)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "--target requires a value"
|
||||
fi
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--run-cargo-check)
|
||||
RUN_CARGO_CHECK=1
|
||||
shift
|
||||
;;
|
||||
--mode)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "--mode requires a value"
|
||||
fi
|
||||
MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--diagnose-log)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "--diagnose-log requires a path"
|
||||
fi
|
||||
DIAGNOSE_LOG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--json-output)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "--json-output requires a path"
|
||||
fi
|
||||
JSON_OUTPUT="$2"
|
||||
if [[ "$JSON_OUTPUT" == "-" ]]; then
|
||||
QUIET=1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--quiet)
|
||||
QUIET=1
|
||||
shift
|
||||
;;
|
||||
--strict)
|
||||
STRICT=1
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "unknown argument: $1 (use --help)"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$TARGET" in
|
||||
aarch64-linux-android|armv7-linux-androideabi) ;;
|
||||
*)
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "unsupported target '$TARGET' (expected aarch64-linux-android or armv7-linux-androideabi)"
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$MODE" in
|
||||
auto|termux-native|ndk-cross) ;;
|
||||
*)
|
||||
ERROR_CODE="BAD_ARGUMENT"
|
||||
die "unsupported mode '$MODE' (expected auto, termux-native, or ndk-cross)"
|
||||
;;
|
||||
esac
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd || pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." >/dev/null 2>&1 && pwd || pwd)"
|
||||
CONFIG_FILE="$REPO_ROOT/.cargo/config.toml"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
TARGET_UPPER="$(printf '%s' "$TARGET" | tr '[:lower:]-' '[:upper:]_')"
|
||||
TARGET_UNDERSCORE="${TARGET//-/_}"
|
||||
CARGO_LINKER_VAR="CARGO_TARGET_${TARGET_UPPER}_LINKER"
|
||||
CC_LINKER_VAR="CC_${TARGET_UNDERSCORE}"
|
||||
|
||||
is_termux=0
|
||||
if [[ -n "${TERMUX_VERSION:-}" ]] || [[ "${PREFIX:-}" == *"/com.termux/files/usr"* ]]; then
|
||||
is_termux=1
|
||||
fi
|
||||
|
||||
effective_mode="$MODE"
|
||||
if [[ "$effective_mode" == "auto" ]]; then
|
||||
if [[ "$is_termux" -eq 1 ]]; then
|
||||
effective_mode="termux-native"
|
||||
else
|
||||
effective_mode="ndk-cross"
|
||||
fi
|
||||
fi
|
||||
OFFLINE_DIAGNOSE=0
|
||||
if [[ -n "$DIAGNOSE_LOG" ]]; then
|
||||
OFFLINE_DIAGNOSE=1
|
||||
fi
|
||||
|
||||
extract_linker_from_config() {
|
||||
[[ -f "$CONFIG_FILE" ]] || return 0
|
||||
awk -v target="$TARGET" '
|
||||
$0 ~ "^\\[target\\." target "\\]$" { in_section=1; next }
|
||||
in_section && $0 ~ "^\\[" { in_section=0 }
|
||||
in_section && $1 == "linker" {
|
||||
gsub(/"/, "", $3);
|
||||
print $3;
|
||||
exit
|
||||
}
|
||||
' "$CONFIG_FILE"
|
||||
}
|
||||
|
||||
command_exists() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
is_executable_tool() {
|
||||
local tool="$1"
|
||||
if [[ "$tool" == */* ]]; then
|
||||
[[ -x "$tool" ]]
|
||||
else
|
||||
command_exists "$tool"
|
||||
fi
|
||||
}
|
||||
|
||||
ndk_wrapper_for_target() {
|
||||
case "$TARGET" in
|
||||
aarch64-linux-android) printf '%s\n' "aarch64-linux-android21-clang" ;;
|
||||
armv7-linux-androideabi) printf '%s\n' "armv7a-linux-androideabi21-clang" ;;
|
||||
*) printf '%s\n' "" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
diagnose_cargo_failure() {
|
||||
local log_file="$1"
|
||||
local ndk_wrapper
|
||||
ndk_wrapper="$(ndk_wrapper_for_target)"
|
||||
|
||||
log "cargo check failed; analyzing common Android toolchain issues..."
|
||||
|
||||
if grep -Eq 'failed to find tool "aarch64-linux-android-clang"|failed to find tool "armv7a-linux-androideabi-clang"|ToolNotFound' "$log_file"; then
|
||||
detect_warn "detected cc-rs compiler lookup failure for Android target"
|
||||
add_detection_code "CC_RS_TOOL_NOT_FOUND"
|
||||
if [[ "$effective_mode" == "termux-native" ]]; then
|
||||
suggest "suggested recovery (termux-native):"
|
||||
suggest " unset $CARGO_LINKER_VAR"
|
||||
suggest " unset $CC_LINKER_VAR"
|
||||
suggest " pkg install -y clang pkg-config"
|
||||
suggest " command -v clang"
|
||||
else
|
||||
suggest "suggested recovery (ndk-cross):"
|
||||
suggest " export NDK_TOOLCHAIN=\"\$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin\""
|
||||
suggest " export $CARGO_LINKER_VAR=\"\$NDK_TOOLCHAIN/$ndk_wrapper\""
|
||||
suggest " export $CC_LINKER_VAR=\"\$NDK_TOOLCHAIN/$ndk_wrapper\""
|
||||
suggest " command -v \"\$NDK_TOOLCHAIN/$ndk_wrapper\""
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -Eq 'linker `clang` not found|linker .* not found|cannot find linker|failed to find tool "clang"' "$log_file"; then
|
||||
detect_warn "detected linker resolution failure"
|
||||
add_detection_code "LINKER_RESOLUTION_FAILURE"
|
||||
if [[ "$effective_mode" == "termux-native" ]]; then
|
||||
suggest "suggested recovery (termux-native):"
|
||||
suggest " pkg install -y clang pkg-config"
|
||||
suggest " command -v clang"
|
||||
else
|
||||
suggest "suggested recovery (ndk-cross):"
|
||||
suggest " export $CARGO_LINKER_VAR=\"\$NDK_TOOLCHAIN/$ndk_wrapper\""
|
||||
suggest " export $CC_LINKER_VAR=\"\$NDK_TOOLCHAIN/$ndk_wrapper\""
|
||||
fi
|
||||
fi
|
||||
|
||||
if grep -Eq "target '$TARGET' not found|can't find crate for std|did you mean to run rustup target add" "$log_file"; then
|
||||
detect_warn "detected missing Rust target stdlib"
|
||||
add_detection_code "MISSING_RUST_TARGET_STDLIB"
|
||||
suggest "suggested recovery:"
|
||||
suggest " rustup target add $TARGET"
|
||||
fi
|
||||
|
||||
if grep -Eq 'No such file or directory \(os error 2\)' "$log_file"; then
|
||||
detect_warn "detected missing binary/file in build chain; verify linker and CC_* variables point to real executables"
|
||||
add_detection_code "MISSING_BINARY_OR_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
log "repo: $REPO_ROOT"
|
||||
log "target: $TARGET"
|
||||
if [[ "$is_termux" -eq 1 ]]; then
|
||||
log "environment: Termux detected"
|
||||
else
|
||||
log "environment: non-Termux (likely desktop/CI)"
|
||||
fi
|
||||
log "mode: $effective_mode"
|
||||
|
||||
if [[ -z "$DIAGNOSE_LOG" ]]; then
|
||||
if ! command_exists rustup; then
|
||||
ERROR_CODE="MISSING_RUSTUP"
|
||||
die "rustup is not installed"
|
||||
fi
|
||||
if ! command_exists cargo; then
|
||||
ERROR_CODE="MISSING_CARGO"
|
||||
die "cargo is not installed"
|
||||
fi
|
||||
|
||||
if ! rustup target list --installed | grep -Fx "$TARGET" >/dev/null 2>&1; then
|
||||
ERROR_CODE="MISSING_RUST_TARGET"
|
||||
die "Rust target '$TARGET' is not installed. Run: rustup target add $TARGET"
|
||||
fi
|
||||
fi
|
||||
|
||||
config_linker="$(extract_linker_from_config || true)"
|
||||
cargo_linker_override="${!CARGO_LINKER_VAR:-}"
|
||||
cc_linker_override="${!CC_LINKER_VAR:-}"
|
||||
|
||||
if [[ -n "$config_linker" ]]; then
|
||||
log "config linker ($TARGET): $config_linker"
|
||||
else
|
||||
warn "no linker configured for $TARGET in .cargo/config.toml"
|
||||
fi
|
||||
|
||||
if [[ -n "$cargo_linker_override" ]]; then
|
||||
log "env override $CARGO_LINKER_VAR=$cargo_linker_override"
|
||||
fi
|
||||
if [[ -n "$cc_linker_override" ]]; then
|
||||
log "env override $CC_LINKER_VAR=$cc_linker_override"
|
||||
fi
|
||||
|
||||
effective_linker="${cargo_linker_override:-${config_linker:-clang}}"
|
||||
log "effective linker: $effective_linker"
|
||||
|
||||
if [[ "$OFFLINE_DIAGNOSE" -eq 0 ]]; then
|
||||
if [[ "$effective_mode" == "termux-native" ]]; then
|
||||
if ! command_exists clang; then
|
||||
if [[ "$is_termux" -eq 1 ]]; then
|
||||
ERROR_CODE="TERMUX_CLANG_MISSING"
|
||||
die "clang is required in Termux. Run: pkg install -y clang pkg-config"
|
||||
fi
|
||||
warn "clang is not available on this non-Termux host; termux-native checks are partial"
|
||||
fi
|
||||
|
||||
if [[ "${config_linker:-}" != "clang" ]]; then
|
||||
warn "Termux native build should use linker = \"clang\" for $TARGET"
|
||||
fi
|
||||
|
||||
if [[ -n "$cargo_linker_override" && "$cargo_linker_override" != "clang" ]]; then
|
||||
warn "Termux native build usually should unset $CARGO_LINKER_VAR (currently '$cargo_linker_override')"
|
||||
fi
|
||||
if [[ -n "$cc_linker_override" && "$cc_linker_override" != "clang" ]]; then
|
||||
warn "Termux native build usually should unset $CC_LINKER_VAR (currently '$cc_linker_override')"
|
||||
fi
|
||||
|
||||
suggest "suggested fixups (termux-native):"
|
||||
suggest " unset $CARGO_LINKER_VAR"
|
||||
suggest " unset $CC_LINKER_VAR"
|
||||
suggest " command -v clang"
|
||||
else
|
||||
if [[ -n "$cargo_linker_override" && -z "$cc_linker_override" ]]; then
|
||||
warn "cross-build may still fail in cc-rs crates; consider setting $CC_LINKER_VAR=$cargo_linker_override"
|
||||
fi
|
||||
|
||||
if [[ -n "$cargo_linker_override" ]]; then
|
||||
suggest "suggested fixup (ndk-cross):"
|
||||
suggest " export $CC_LINKER_VAR=\"$cargo_linker_override\""
|
||||
else
|
||||
warn "NDK cross mode expects $CARGO_LINKER_VAR to point to an NDK clang wrapper"
|
||||
suggest "suggested fixup template (ndk-cross):"
|
||||
suggest " export NDK_TOOLCHAIN=\"\$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin\""
|
||||
if [[ "$TARGET" == "aarch64-linux-android" ]]; then
|
||||
suggest " export $CARGO_LINKER_VAR=\"\$NDK_TOOLCHAIN/aarch64-linux-android21-clang\""
|
||||
suggest " export $CC_LINKER_VAR=\"\$NDK_TOOLCHAIN/aarch64-linux-android21-clang\""
|
||||
else
|
||||
suggest " export $CARGO_LINKER_VAR=\"\$NDK_TOOLCHAIN/armv7a-linux-androideabi21-clang\""
|
||||
suggest " export $CC_LINKER_VAR=\"\$NDK_TOOLCHAIN/armv7a-linux-androideabi21-clang\""
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! is_executable_tool "$effective_linker"; then
|
||||
if [[ "$effective_mode" == "termux-native" ]]; then
|
||||
if [[ "$is_termux" -eq 1 ]]; then
|
||||
ERROR_CODE="LINKER_NOT_EXECUTABLE"
|
||||
die "effective linker '$effective_linker' is not executable in PATH"
|
||||
fi
|
||||
warn "effective linker '$effective_linker' not executable on this non-Termux host"
|
||||
else
|
||||
warn "effective linker '$effective_linker' not found (expected for some desktop hosts without NDK toolchain)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "$DIAGNOSE_LOG" ]]; then
|
||||
if [[ ! -f "$DIAGNOSE_LOG" ]]; then
|
||||
ERROR_CODE="MISSING_DIAGNOSE_LOG"
|
||||
die "diagnose log file does not exist: $DIAGNOSE_LOG"
|
||||
fi
|
||||
log "diagnosing provided cargo log: $DIAGNOSE_LOG"
|
||||
diagnose_cargo_failure "$DIAGNOSE_LOG"
|
||||
log "diagnosis completed"
|
||||
enforce_strict_mode
|
||||
emit_json_report 0
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$RUN_CARGO_CHECK" -eq 1 ]]; then
|
||||
tmp_log="$(mktemp -t zeroclaw-android-check-XXXXXX.log)"
|
||||
cleanup_tmp_log() {
|
||||
rm -f "$tmp_log"
|
||||
}
|
||||
trap cleanup_tmp_log EXIT
|
||||
|
||||
log "running cargo check --locked --target $TARGET --no-default-features"
|
||||
set +e
|
||||
CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-/tmp/zeroclaw-android-selfcheck-target}" \
|
||||
cargo check --locked --target "$TARGET" --no-default-features 2>&1 | tee "$tmp_log"
|
||||
cargo_status="${PIPESTATUS[0]}"
|
||||
set -e
|
||||
|
||||
if [[ "$cargo_status" -ne 0 ]]; then
|
||||
diagnose_cargo_failure "$tmp_log"
|
||||
ERROR_CODE="CARGO_CHECK_FAILED"
|
||||
die "cargo check failed (exit $cargo_status)"
|
||||
fi
|
||||
|
||||
log "cargo check completed successfully"
|
||||
else
|
||||
log "skip cargo check (use --run-cargo-check to enable)"
|
||||
fi
|
||||
|
||||
log "self-check completed"
|
||||
enforce_strict_mode
|
||||
emit_json_report 0
|
||||
67
scripts/ci/m4_5_rfi_baseline.sh
Executable file
67
scripts/ci/m4_5_rfi_baseline.sh
Executable file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/ci/m4_5_rfi_baseline.sh [target_dir]
|
||||
|
||||
Run reproducible compile-timing probes for the current workspace.
|
||||
The script prints a markdown table with real-time seconds and pass/fail status
|
||||
for each benchmark phase.
|
||||
USAGE
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TARGET_DIR="${1:-${ROOT_DIR}/target-rfi}"
|
||||
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if [[ ! -f Cargo.toml ]]; then
|
||||
echo "error: Cargo.toml not found at ${ROOT_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_timed() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
local timing_file
|
||||
timing_file="$(mktemp)"
|
||||
local status="pass"
|
||||
|
||||
if /usr/bin/time -p "$@" >/dev/null 2>"${timing_file}"; then
|
||||
status="pass"
|
||||
else
|
||||
status="fail"
|
||||
fi
|
||||
|
||||
local real_time
|
||||
real_time="$(awk '/^real / { print $2 }' "${timing_file}")"
|
||||
rm -f "${timing_file}"
|
||||
|
||||
if [[ -z "${real_time}" ]]; then
|
||||
real_time="n/a"
|
||||
fi
|
||||
|
||||
printf '| %s | %s | %s |\n' "${label}" "${real_time}" "${status}"
|
||||
|
||||
[[ "${status}" == "pass" ]]
|
||||
}
|
||||
|
||||
printf '# M4-5 RFI Baseline\n\n'
|
||||
printf '- Timestamp (UTC): %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf '- Commit: `%s`\n' "$(git rev-parse --short HEAD)"
|
||||
printf '- Target dir: `%s`\n\n' "${TARGET_DIR}"
|
||||
printf '| Phase | real(s) | status |\n'
|
||||
printf '|---|---:|---|\n'
|
||||
|
||||
rm -rf "${TARGET_DIR}"
|
||||
|
||||
set +e
|
||||
run_timed "A: cold cargo check" env CARGO_TARGET_DIR="${TARGET_DIR}" cargo check --workspace --locked
|
||||
run_timed "B: cold-ish cargo build" env CARGO_TARGET_DIR="${TARGET_DIR}" cargo build --workspace --locked
|
||||
run_timed "C: warm cargo check" env CARGO_TARGET_DIR="${TARGET_DIR}" cargo check --workspace --locked
|
||||
touch src/main.rs
|
||||
run_timed "D: incremental cargo check after touch src/main.rs" env CARGO_TARGET_DIR="${TARGET_DIR}" cargo check --workspace --locked
|
||||
set -e
|
||||
@ -20,6 +20,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPTS_DIR = ROOT / "scripts" / "ci"
|
||||
ANDROID_SCRIPTS_DIR = ROOT / "scripts" / "android"
|
||||
|
||||
|
||||
def run_cmd(
|
||||
@ -92,6 +93,244 @@ class CiScriptsBehaviorTest(unittest.TestCase):
|
||||
def _script(self, name: str) -> str:
|
||||
return str(SCRIPTS_DIR / name)
|
||||
|
||||
def _android_script(self, name: str) -> str:
|
||||
return str(ANDROID_SCRIPTS_DIR / name)
|
||||
|
||||
def test_android_selfcheck_help_mentions_modes(self) -> None:
|
||||
proc = run_cmd(["bash", self._android_script("termux_source_build_check.sh"), "--help"])
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr)
|
||||
self.assertIn("--mode <auto|termux-native|ndk-cross>", proc.stdout)
|
||||
self.assertIn("--diagnose-log <p>", proc.stdout)
|
||||
self.assertIn("--json-output <p|-]", proc.stdout)
|
||||
self.assertIn("--quiet", proc.stdout)
|
||||
self.assertIn("--strict", proc.stdout)
|
||||
|
||||
def test_android_selfcheck_diagnose_log_ndk_cross(self) -> None:
|
||||
log_path = self.tmp / "android-failure.log"
|
||||
log_path.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
error occurred in cc-rs: failed to find tool "aarch64-linux-android-clang": No such file or directory (os error 2)
|
||||
"""
|
||||
).strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"ndk-cross",
|
||||
"--diagnose-log",
|
||||
str(log_path),
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr)
|
||||
combined = f"{proc.stdout}\n{proc.stderr}"
|
||||
self.assertIn("detected cc-rs compiler lookup failure", combined)
|
||||
self.assertIn("export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER", combined)
|
||||
self.assertIn("export CC_aarch64_linux_android", combined)
|
||||
|
||||
def test_android_selfcheck_diagnose_log_termux_native(self) -> None:
|
||||
log_path = self.tmp / "android-failure-termux.log"
|
||||
log_path.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
error occurred in cc-rs: failed to find tool "aarch64-linux-android-clang": No such file or directory (os error 2)
|
||||
"""
|
||||
).strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"termux-native",
|
||||
"--diagnose-log",
|
||||
str(log_path),
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr)
|
||||
combined = f"{proc.stdout}\n{proc.stderr}"
|
||||
self.assertIn("suggested recovery (termux-native)", combined)
|
||||
self.assertIn("unset CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER", combined)
|
||||
|
||||
def test_android_selfcheck_json_output_on_diagnose_success(self) -> None:
|
||||
log_path = self.tmp / "android-failure-json.log"
|
||||
json_path = self.tmp / "android-selfcheck.json"
|
||||
log_path.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
error occurred in cc-rs: failed to find tool "aarch64-linux-android-clang": No such file or directory (os error 2)
|
||||
"""
|
||||
).strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"ndk-cross",
|
||||
"--diagnose-log",
|
||||
str(log_path),
|
||||
"--json-output",
|
||||
str(json_path),
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr)
|
||||
report = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["schema_version"], "zeroclaw.android-selfcheck.v1")
|
||||
self.assertEqual(report["status"], "ok")
|
||||
self.assertEqual(report["error_code"], "NONE")
|
||||
self.assertFalse(report["strict_mode"])
|
||||
self.assertEqual(report["target"], "aarch64-linux-android")
|
||||
self.assertEqual(report["mode_effective"], "ndk-cross")
|
||||
self.assertTrue(any("cc-rs compiler lookup failure" in x for x in report["detections"]))
|
||||
self.assertIn("CC_RS_TOOL_NOT_FOUND", report["detection_codes"])
|
||||
self.assertTrue(any("CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER" in x for x in report["suggestions"]))
|
||||
|
||||
def test_android_selfcheck_json_output_on_missing_diagnose_log(self) -> None:
|
||||
missing_log = self.tmp / "missing.log"
|
||||
json_path = self.tmp / "android-selfcheck-error.json"
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"ndk-cross",
|
||||
"--diagnose-log",
|
||||
str(missing_log),
|
||||
"--json-output",
|
||||
str(json_path),
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
report = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["status"], "error")
|
||||
self.assertEqual(report["exit_code"], 1)
|
||||
self.assertEqual(report["error_code"], "MISSING_DIAGNOSE_LOG")
|
||||
self.assertIn("does not exist", report["error_message"])
|
||||
|
||||
def test_android_selfcheck_json_stdout_mode(self) -> None:
|
||||
log_path = self.tmp / "android-failure-stdout.log"
|
||||
log_path.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
error occurred in cc-rs: failed to find tool "aarch64-linux-android-clang": No such file or directory (os error 2)
|
||||
"""
|
||||
).strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"ndk-cross",
|
||||
"--diagnose-log",
|
||||
str(log_path),
|
||||
"--json-output",
|
||||
"-",
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr)
|
||||
report = json.loads(proc.stdout)
|
||||
self.assertEqual(report["status"], "ok")
|
||||
self.assertEqual(report["mode_effective"], "ndk-cross")
|
||||
|
||||
def test_android_selfcheck_strict_fails_when_warnings_present(self) -> None:
|
||||
log_path = self.tmp / "android-failure-strict.log"
|
||||
json_path = self.tmp / "android-selfcheck-strict-error.json"
|
||||
log_path.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
error occurred in cc-rs: failed to find tool "aarch64-linux-android-clang": No such file or directory (os error 2)
|
||||
"""
|
||||
).strip()
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"ndk-cross",
|
||||
"--diagnose-log",
|
||||
str(log_path),
|
||||
"--json-output",
|
||||
str(json_path),
|
||||
"--strict",
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
report = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["status"], "error")
|
||||
self.assertEqual(report["error_code"], "STRICT_WARNINGS")
|
||||
self.assertTrue(report["strict_mode"])
|
||||
self.assertGreater(report["warning_count"], 0)
|
||||
|
||||
def test_android_selfcheck_strict_passes_without_warnings(self) -> None:
|
||||
log_path = self.tmp / "android-clean-strict.log"
|
||||
json_path = self.tmp / "android-selfcheck-strict-ok.json"
|
||||
log_path.write_text("build completed cleanly\n", encoding="utf-8")
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--target",
|
||||
"aarch64-linux-android",
|
||||
"--mode",
|
||||
"ndk-cross",
|
||||
"--diagnose-log",
|
||||
str(log_path),
|
||||
"--json-output",
|
||||
str(json_path),
|
||||
"--strict",
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 0, msg=proc.stderr)
|
||||
report = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["status"], "ok")
|
||||
self.assertEqual(report["error_code"], "NONE")
|
||||
self.assertEqual(report["warning_count"], 0)
|
||||
self.assertTrue(report["strict_mode"])
|
||||
|
||||
def test_android_selfcheck_bad_argument_reports_error_code(self) -> None:
|
||||
json_path = self.tmp / "android-selfcheck-bad-arg.json"
|
||||
proc = run_cmd(
|
||||
[
|
||||
"bash",
|
||||
self._android_script("termux_source_build_check.sh"),
|
||||
"--mode",
|
||||
"invalid-mode",
|
||||
"--json-output",
|
||||
str(json_path),
|
||||
]
|
||||
)
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
report = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(report["status"], "error")
|
||||
self.assertEqual(report["error_code"], "BAD_ARGUMENT")
|
||||
|
||||
def test_emit_audit_event_envelope(self) -> None:
|
||||
payload_path = self.tmp / "payload.json"
|
||||
output_path = self.tmp / "event.json"
|
||||
|
||||
@ -218,9 +218,7 @@ impl AgentBuilder {
|
||||
.memory_loader
|
||||
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
|
||||
config: self.config.unwrap_or_default(),
|
||||
model_name: self
|
||||
.model_name
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into()),
|
||||
model_name: crate::config::resolve_default_model_id(self.model_name.as_deref(), None),
|
||||
temperature: self.temperature.unwrap_or(0.7),
|
||||
workspace_dir: self
|
||||
.workspace_dir
|
||||
@ -298,11 +296,10 @@ impl Agent {
|
||||
|
||||
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
|
||||
|
||||
let model_name = config
|
||||
.default_model
|
||||
.as_deref()
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514")
|
||||
.to_string();
|
||||
let model_name = crate::config::resolve_default_model_id(
|
||||
config.default_model.as_deref(),
|
||||
Some(provider_name),
|
||||
);
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_routed_provider(
|
||||
provider_name,
|
||||
@ -714,8 +711,12 @@ pub async fn run(
|
||||
let model_name = effective_config
|
||||
.default_model
|
||||
.as_deref()
|
||||
.unwrap_or("anthropic/claude-sonnet-4-20250514")
|
||||
.to_string();
|
||||
.map(str::trim)
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
crate::config::default_model_fallback_for_provider(Some(&provider_name)).to_string()
|
||||
});
|
||||
|
||||
agent.observer.record_event(&ObserverEvent::AgentStart {
|
||||
provider: provider_name.clone(),
|
||||
@ -776,6 +777,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
@ -813,6 +815,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
@ -852,6 +855,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}]),
|
||||
});
|
||||
|
||||
@ -892,12 +896,14 @@ mod tests {
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
},
|
||||
crate::providers::ChatResponse {
|
||||
text: Some("done".into()),
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
},
|
||||
]),
|
||||
});
|
||||
@ -939,6 +945,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}]),
|
||||
seen_models: seen_models.clone(),
|
||||
});
|
||||
|
||||
@ -263,6 +263,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
let (_, calls) = dispatcher.parse_response(&response);
|
||||
@ -281,6 +282,7 @@ mod tests {
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
let dispatcher = NativeToolDispatcher;
|
||||
let (_, calls) = dispatcher.parse_response(&response);
|
||||
|
||||
@ -290,6 +290,20 @@ pub(crate) struct NonCliApprovalContext {
|
||||
tokio::task_local! {
|
||||
static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option<NonCliApprovalContext>;
|
||||
static LOOP_DETECTION_CONFIG: LoopDetectionConfig;
|
||||
static SAFETY_HEARTBEAT_CONFIG: Option<SafetyHeartbeatConfig>;
|
||||
}
|
||||
|
||||
/// Configuration for periodic safety-constraint re-injection (heartbeat).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SafetyHeartbeatConfig {
|
||||
/// Pre-rendered security policy summary text.
|
||||
pub body: String,
|
||||
/// Inject a heartbeat every `interval` tool iterations (0 = disabled).
|
||||
pub interval: usize,
|
||||
}
|
||||
|
||||
fn should_inject_safety_heartbeat(counter: usize, interval: usize) -> bool {
|
||||
interval > 0 && counter > 0 && counter % interval == 0
|
||||
}
|
||||
|
||||
/// Extract a short hint from tool call arguments for progress display.
|
||||
@ -687,33 +701,37 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
|
||||
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
|
||||
hooks: Option<&crate::hooks::HookRunner>,
|
||||
excluded_tools: &[String],
|
||||
safety_heartbeat: Option<SafetyHeartbeatConfig>,
|
||||
) -> Result<String> {
|
||||
let reply_target = non_cli_approval_context
|
||||
.as_ref()
|
||||
.map(|ctx| ctx.reply_target.clone());
|
||||
|
||||
TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT
|
||||
SAFETY_HEARTBEAT_CONFIG
|
||||
.scope(
|
||||
non_cli_approval_context,
|
||||
TOOL_LOOP_REPLY_TARGET.scope(
|
||||
reply_target,
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
history,
|
||||
tools_registry,
|
||||
observer,
|
||||
provider_name,
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
approval,
|
||||
channel_name,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
cancellation_token,
|
||||
on_delta,
|
||||
hooks,
|
||||
excluded_tools,
|
||||
safety_heartbeat,
|
||||
TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT.scope(
|
||||
non_cli_approval_context,
|
||||
TOOL_LOOP_REPLY_TARGET.scope(
|
||||
reply_target,
|
||||
run_tool_call_loop(
|
||||
provider,
|
||||
history,
|
||||
tools_registry,
|
||||
observer,
|
||||
provider_name,
|
||||
model,
|
||||
temperature,
|
||||
silent,
|
||||
approval,
|
||||
channel_name,
|
||||
multimodal_config,
|
||||
max_tool_iterations,
|
||||
cancellation_token,
|
||||
on_delta,
|
||||
hooks,
|
||||
excluded_tools,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -788,6 +806,10 @@ pub(crate) async fn run_tool_call_loop(
|
||||
.unwrap_or_default();
|
||||
let mut loop_detector = LoopDetector::new(ld_config);
|
||||
let mut loop_detection_prompt: Option<String> = None;
|
||||
let heartbeat_config = SAFETY_HEARTBEAT_CONFIG
|
||||
.try_with(Clone::clone)
|
||||
.ok()
|
||||
.flatten();
|
||||
let bypass_non_cli_approval_for_turn =
|
||||
approval.is_some_and(|mgr| channel_name != "cli" && mgr.consume_non_cli_allow_all_once());
|
||||
if bypass_non_cli_approval_for_turn {
|
||||
@ -835,6 +857,19 @@ pub(crate) async fn run_tool_call_loop(
|
||||
request_messages.push(ChatMessage::user(prompt));
|
||||
}
|
||||
|
||||
// ── Safety heartbeat: periodic security-constraint re-injection ──
|
||||
if let Some(ref hb) = heartbeat_config {
|
||||
if should_inject_safety_heartbeat(iteration, hb.interval) {
|
||||
let reminder = format!(
|
||||
"[Safety Heartbeat — round {}/{}]\n{}",
|
||||
iteration + 1,
|
||||
max_iterations,
|
||||
hb.body
|
||||
);
|
||||
request_messages.push(ChatMessage::user(reminder));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress: LLM thinking ────────────────────────────
|
||||
if let Some(ref tx) = on_delta {
|
||||
let phase = if iteration == 0 {
|
||||
@ -1244,13 +1279,30 @@ pub(crate) async fn run_tool_call_loop(
|
||||
|
||||
// ── Approval hook ────────────────────────────────
|
||||
if let Some(mgr) = approval {
|
||||
if bypass_non_cli_approval_for_turn {
|
||||
let non_cli_session_granted =
|
||||
channel_name != "cli" && mgr.is_non_cli_session_granted(&tool_name);
|
||||
if bypass_non_cli_approval_for_turn || non_cli_session_granted {
|
||||
mgr.record_decision(
|
||||
&tool_name,
|
||||
&tool_args,
|
||||
ApprovalResponse::Yes,
|
||||
channel_name,
|
||||
);
|
||||
if non_cli_session_granted {
|
||||
runtime_trace::record_event(
|
||||
"approval_bypass_non_cli_session_grant",
|
||||
Some(channel_name),
|
||||
Some(provider_name),
|
||||
Some(model),
|
||||
Some(&turn_id),
|
||||
Some(true),
|
||||
Some("using runtime non-cli session approval grant"),
|
||||
serde_json::json!({
|
||||
"iteration": iteration + 1,
|
||||
"tool": tool_name.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if mgr.needs_approval(&tool_name) {
|
||||
let request = ApprovalRequest {
|
||||
tool_name: tool_name.clone(),
|
||||
@ -1765,10 +1817,12 @@ pub async fn run(
|
||||
.or(config.default_provider.as_deref())
|
||||
.unwrap_or("openrouter");
|
||||
|
||||
let model_name = model_override
|
||||
.as_deref()
|
||||
.or(config.default_model.as_deref())
|
||||
.unwrap_or("anthropic/claude-sonnet-4");
|
||||
let model_name = crate::config::resolve_default_model_id(
|
||||
model_override
|
||||
.as_deref()
|
||||
.or(config.default_model.as_deref()),
|
||||
Some(provider_name),
|
||||
);
|
||||
|
||||
let provider_runtime_options = providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
@ -1789,7 +1843,7 @@ pub async fn run(
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
model_name,
|
||||
&model_name,
|
||||
&provider_runtime_options,
|
||||
)?;
|
||||
|
||||
@ -1837,6 +1891,10 @@ pub async fn run(
|
||||
"memory_store",
|
||||
"Save to memory. Use when: preserving durable preferences, decisions, key context. Don't use when: information is transient/noisy/sensitive without need.",
|
||||
),
|
||||
(
|
||||
"memory_observe",
|
||||
"Store observation memory. Use when: capturing patterns/signals that should remain searchable over long horizons.",
|
||||
),
|
||||
(
|
||||
"memory_recall",
|
||||
"Search memory. Use when: retrieving prior decisions, user preferences, historical context. Don't use when: answer is already in current context.",
|
||||
@ -1948,7 +2006,7 @@ pub async fn run(
|
||||
let native_tools = provider.supports_native_tools();
|
||||
let mut system_prompt = crate::channels::build_system_prompt_with_mode(
|
||||
&config.workspace_dir,
|
||||
model_name,
|
||||
&model_name,
|
||||
&tool_descs,
|
||||
&skills,
|
||||
Some(&config.identity),
|
||||
@ -1987,7 +2045,7 @@ pub async fn run(
|
||||
|
||||
// Inject memory + hardware RAG context into user message
|
||||
let mem_context =
|
||||
build_context(mem.as_ref(), &msg, config.memory.min_relevance_score).await;
|
||||
build_context(mem.as_ref(), &msg, config.memory.min_relevance_score, None).await;
|
||||
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
|
||||
let hw_context = hardware_rag
|
||||
.as_ref()
|
||||
@ -2011,26 +2069,37 @@ pub async fn run(
|
||||
ping_pong_cycles: config.agent.loop_detection_ping_pong_cycles,
|
||||
failure_streak_threshold: config.agent.loop_detection_failure_streak,
|
||||
};
|
||||
let response = LOOP_DETECTION_CONFIG
|
||||
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
|
||||
Some(SafetyHeartbeatConfig {
|
||||
body: security.summary_for_heartbeat(),
|
||||
interval: config.agent.safety_heartbeat_interval,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let response = SAFETY_HEARTBEAT_CONFIG
|
||||
.scope(
|
||||
ld_cfg,
|
||||
run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
hb_cfg,
|
||||
LOOP_DETECTION_CONFIG.scope(
|
||||
ld_cfg,
|
||||
run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
&model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
@ -2044,6 +2113,7 @@ pub async fn run(
|
||||
|
||||
// Persistent conversation history across turns
|
||||
let mut history = vec![ChatMessage::system(&system_prompt)];
|
||||
let mut interactive_turn: usize = 0;
|
||||
// Reusable readline editor for UTF-8 input support
|
||||
let mut rl = Editor::with_config(
|
||||
RlConfig::builder()
|
||||
@ -2094,6 +2164,7 @@ pub async fn run(
|
||||
rl.clear_history()?;
|
||||
history.clear();
|
||||
history.push(ChatMessage::system(&system_prompt));
|
||||
interactive_turn = 0;
|
||||
// Clear conversation and daily memory
|
||||
let mut cleared = 0;
|
||||
for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
|
||||
@ -2123,8 +2194,13 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// Inject memory + hardware RAG context into user message
|
||||
let mem_context =
|
||||
build_context(mem.as_ref(), &user_input, config.memory.min_relevance_score).await;
|
||||
let mem_context = build_context(
|
||||
mem.as_ref(),
|
||||
&user_input,
|
||||
config.memory.min_relevance_score,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
|
||||
let hw_context = hardware_rag
|
||||
.as_ref()
|
||||
@ -2139,32 +2215,57 @@ pub async fn run(
|
||||
};
|
||||
|
||||
history.push(ChatMessage::user(&enriched));
|
||||
interactive_turn += 1;
|
||||
|
||||
// Inject interactive safety heartbeat at configured turn intervals
|
||||
if should_inject_safety_heartbeat(
|
||||
interactive_turn,
|
||||
config.agent.safety_heartbeat_turn_interval,
|
||||
) {
|
||||
let reminder = format!(
|
||||
"[Safety Heartbeat — turn {}]\n{}",
|
||||
interactive_turn,
|
||||
security.summary_for_heartbeat()
|
||||
);
|
||||
history.push(ChatMessage::user(reminder));
|
||||
}
|
||||
|
||||
let ld_cfg = LoopDetectionConfig {
|
||||
no_progress_threshold: config.agent.loop_detection_no_progress_threshold,
|
||||
ping_pong_cycles: config.agent.loop_detection_ping_pong_cycles,
|
||||
failure_streak_threshold: config.agent.loop_detection_failure_streak,
|
||||
};
|
||||
let response = match LOOP_DETECTION_CONFIG
|
||||
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
|
||||
Some(SafetyHeartbeatConfig {
|
||||
body: security.summary_for_heartbeat(),
|
||||
interval: config.agent.safety_heartbeat_interval,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let response = match SAFETY_HEARTBEAT_CONFIG
|
||||
.scope(
|
||||
ld_cfg,
|
||||
run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
hb_cfg,
|
||||
LOOP_DETECTION_CONFIG.scope(
|
||||
ld_cfg,
|
||||
run_tool_call_loop(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
&model_name,
|
||||
temperature,
|
||||
false,
|
||||
approval_manager.as_ref(),
|
||||
channel_name,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
),
|
||||
),
|
||||
)
|
||||
.await
|
||||
@ -2209,7 +2310,7 @@ pub async fn run(
|
||||
if let Ok(compacted) = auto_compact_history(
|
||||
&mut history,
|
||||
provider.as_ref(),
|
||||
model_name,
|
||||
&model_name,
|
||||
config.agent.max_history_messages,
|
||||
)
|
||||
.await
|
||||
@ -2238,13 +2339,15 @@ pub async fn run(
|
||||
|
||||
/// Process a single message through the full agent (with tools, peripherals, memory).
|
||||
/// Used by channels (Telegram, Discord, etc.) to enable hardware and tool use.
|
||||
pub async fn process_message(
|
||||
pub async fn process_message(config: Config, message: &str) -> Result<String> {
|
||||
process_message_with_session(config, message, None).await
|
||||
}
|
||||
|
||||
pub async fn process_message_with_session(
|
||||
config: Config,
|
||||
message: &str,
|
||||
sender_id: &str,
|
||||
channel_name: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> Result<String> {
|
||||
tracing::debug!(sender_id, channel_name, "process_message called");
|
||||
let observer: Arc<dyn Observer> =
|
||||
Arc::from(observability::create_observer(&config.observability));
|
||||
let runtime: Arc<dyn runtime::RuntimeAdapter> =
|
||||
@ -2288,10 +2391,10 @@ pub async fn process_message(
|
||||
tools_registry.extend(peripheral_tools);
|
||||
|
||||
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
|
||||
let model_name = config
|
||||
.default_model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic/claude-sonnet-4-20250514".into());
|
||||
let model_name = crate::config::resolve_default_model_id(
|
||||
config.default_model.as_deref(),
|
||||
Some(provider_name),
|
||||
);
|
||||
let provider_runtime_options = providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
provider_api_url: config.api_url.clone(),
|
||||
@ -2335,6 +2438,7 @@ pub async fn process_message(
|
||||
("file_read", "Read file contents."),
|
||||
("file_write", "Write file contents."),
|
||||
("memory_store", "Save to memory."),
|
||||
("memory_observe", "Store observation memory."),
|
||||
("memory_recall", "Search memory."),
|
||||
("memory_forget", "Delete a memory entry."),
|
||||
(
|
||||
@ -2407,7 +2511,13 @@ pub async fn process_message(
|
||||
}
|
||||
system_prompt.push_str(&build_shell_policy_instructions(&config.autonomy));
|
||||
|
||||
let mem_context = build_context(mem.as_ref(), message, config.memory.min_relevance_score).await;
|
||||
let mem_context = build_context(
|
||||
mem.as_ref(),
|
||||
message,
|
||||
config.memory.min_relevance_score,
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
let rag_limit = if config.agent.compact_context { 2 } else { 5 };
|
||||
let hw_context = hardware_rag
|
||||
.as_ref()
|
||||
@ -2433,53 +2543,31 @@ pub async fn process_message(
|
||||
.filter(|m| crate::providers::is_user_or_assistant_role(m.role.as_str()))
|
||||
.collect();
|
||||
|
||||
let mut history = Vec::new();
|
||||
history.push(ChatMessage::system(&system_prompt));
|
||||
history.extend(filtered_history);
|
||||
history.push(ChatMessage::user(&enriched));
|
||||
let reply = agent_turn(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
&model_name,
|
||||
config.default_temperature,
|
||||
true,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
)
|
||||
.await?;
|
||||
let persisted: Vec<ChatMessage> = history
|
||||
.into_iter()
|
||||
.filter(|m| crate::providers::is_user_or_assistant_role(m.role.as_str()))
|
||||
.collect();
|
||||
let saved_len = persisted.len();
|
||||
session
|
||||
.update_history(persisted)
|
||||
.await
|
||||
.context("Failed to update session history")?;
|
||||
tracing::debug!(saved_len, "session history saved");
|
||||
Ok(reply)
|
||||
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
|
||||
Some(SafetyHeartbeatConfig {
|
||||
body: security.summary_for_heartbeat(),
|
||||
interval: config.agent.safety_heartbeat_interval,
|
||||
})
|
||||
} else {
|
||||
let mut history = vec![
|
||||
ChatMessage::system(&system_prompt),
|
||||
ChatMessage::user(&enriched),
|
||||
];
|
||||
agent_turn(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
&model_name,
|
||||
config.default_temperature,
|
||||
true,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
None
|
||||
};
|
||||
SAFETY_HEARTBEAT_CONFIG
|
||||
.scope(
|
||||
hb_cfg,
|
||||
agent_turn(
|
||||
provider.as_ref(),
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
observer.as_ref(),
|
||||
provider_name,
|
||||
&model_name,
|
||||
config.default_temperature,
|
||||
true,
|
||||
&config.multimodal,
|
||||
config.agent.max_tool_iterations,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -2574,6 +2662,36 @@ mod tests {
|
||||
assert_eq!(feishu_args["delivery"]["to"], "oc_yyy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_heartbeat_interval_zero_disables_injection() {
|
||||
for counter in [0, 1, 2, 10, 100] {
|
||||
assert!(
|
||||
!should_inject_safety_heartbeat(counter, 0),
|
||||
"counter={counter} should not inject when interval=0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_heartbeat_interval_one_injects_every_non_initial_step() {
|
||||
assert!(!should_inject_safety_heartbeat(0, 1));
|
||||
for counter in 1..=6 {
|
||||
assert!(
|
||||
should_inject_safety_heartbeat(counter, 1),
|
||||
"counter={counter} should inject when interval=1"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safety_heartbeat_injects_only_on_exact_multiples() {
|
||||
let interval = 3;
|
||||
let injected: Vec<usize> = (0..=10)
|
||||
.filter(|counter| should_inject_safety_heartbeat(*counter, interval))
|
||||
.collect();
|
||||
assert_eq!(injected, vec![3, 6, 9]);
|
||||
}
|
||||
|
||||
use crate::memory::{Memory, MemoryCategory, SqliteMemory};
|
||||
use crate::observability::NoopObserver;
|
||||
use crate::providers::traits::ProviderCapabilities;
|
||||
@ -2643,6 +2761,7 @@ mod tests {
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -2661,6 +2780,7 @@ mod tests {
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
@ -3183,6 +3303,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_uses_non_cli_session_grant_without_waiting_for_prompt() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
r#"<tool_call>
|
||||
{"name":"shell","arguments":{"command":"echo hi"}}
|
||||
</tool_call>"#,
|
||||
"done",
|
||||
]);
|
||||
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let max_active = Arc::new(AtomicUsize::new(0));
|
||||
let tools_registry: Vec<Box<dyn Tool>> = vec![Box::new(DelayTool::new(
|
||||
"shell",
|
||||
50,
|
||||
Arc::clone(&active),
|
||||
Arc::clone(&max_active),
|
||||
))];
|
||||
|
||||
let approval_mgr = ApprovalManager::from_config(&crate::config::AutonomyConfig::default());
|
||||
approval_mgr.grant_non_cli_session("shell");
|
||||
|
||||
let mut history = vec![
|
||||
ChatMessage::system("test-system"),
|
||||
ChatMessage::user("run shell"),
|
||||
];
|
||||
let observer = NoopObserver;
|
||||
|
||||
let result = run_tool_call_loop(
|
||||
&provider,
|
||||
&mut history,
|
||||
&tools_registry,
|
||||
&observer,
|
||||
"mock-provider",
|
||||
"mock-model",
|
||||
0.0,
|
||||
true,
|
||||
Some(&approval_mgr),
|
||||
"telegram",
|
||||
&crate::config::MultimodalConfig::default(),
|
||||
4,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.expect("tool loop should consume non-cli session grants");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
assert_eq!(
|
||||
max_active.load(Ordering::SeqCst),
|
||||
1,
|
||||
"shell tool should execute when runtime non-cli session grant exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_loop_waits_for_non_cli_approval_resolution() {
|
||||
let provider = ScriptedProvider::from_text_responses(vec![
|
||||
@ -3252,6 +3428,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("tool loop should continue after non-cli approval");
|
||||
@ -4397,7 +4574,7 @@ Tail"#;
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let context = build_context(&mem, "status updates", 0.0).await;
|
||||
let context = build_context(&mem, "status updates", 0.0, None).await;
|
||||
assert!(context.contains("user_msg_real"));
|
||||
assert!(!context.contains("assistant_resp_poisoned"));
|
||||
assert!(!context.contains("fabricated event"));
|
||||
|
||||
@ -8,11 +8,12 @@ pub(super) async fn build_context(
|
||||
mem: &dyn Memory,
|
||||
user_msg: &str,
|
||||
min_relevance_score: f64,
|
||||
session_id: Option<&str>,
|
||||
) -> String {
|
||||
let mut context = String::new();
|
||||
|
||||
// Pull relevant memories for this message
|
||||
if let Ok(entries) = mem.recall(user_msg, 5, None).await {
|
||||
if let Ok(entries) = mem.recall(user_msg, 5, session_id).await {
|
||||
let relevant: Vec<_> = entries
|
||||
.iter()
|
||||
.filter(|e| match e.score {
|
||||
|
||||
@ -220,7 +220,9 @@ impl LoopDetector {
|
||||
|
||||
fn hash_output(output: &str) -> u64 {
|
||||
let prefix = if output.len() > OUTPUT_HASH_PREFIX_BYTES {
|
||||
&output[..OUTPUT_HASH_PREFIX_BYTES]
|
||||
// Use floor_utf8_char_boundary to avoid panic on multi-byte UTF-8 characters
|
||||
let boundary = crate::util::floor_utf8_char_boundary(output, OUTPUT_HASH_PREFIX_BYTES);
|
||||
&output[..boundary]
|
||||
} else {
|
||||
output
|
||||
};
|
||||
@ -386,4 +388,26 @@ mod tests {
|
||||
det.record_call("shell", r#"{"cmd":"cargo test"}"#, "ok", true);
|
||||
assert_eq!(det.check(), DetectionVerdict::Continue);
|
||||
}
|
||||
|
||||
// 11. UTF-8 boundary safety: hash_output must not panic on CJK text
|
||||
#[test]
|
||||
fn hash_output_utf8_boundary_safe() {
|
||||
// Create a string where byte 4096 lands inside a multi-byte char
|
||||
// Chinese chars are 3 bytes each, so 1366 chars = 4098 bytes
|
||||
let cjk_text: String = "文".repeat(1366); // 4098 bytes
|
||||
assert!(cjk_text.len() > super::OUTPUT_HASH_PREFIX_BYTES);
|
||||
|
||||
// This should NOT panic
|
||||
let hash1 = super::hash_output(&cjk_text);
|
||||
|
||||
// Different content should produce different hash
|
||||
let cjk_text2: String = "字".repeat(1366);
|
||||
let hash2 = super::hash_output(&cjk_text2);
|
||||
assert_ne!(hash1, hash2);
|
||||
|
||||
// Mixed ASCII + CJK at boundary
|
||||
let mixed = "a".repeat(4094) + "文文"; // 4094 + 6 = 4100 bytes, boundary at 4096
|
||||
let hash3 = super::hash_output(&mixed);
|
||||
assert!(hash3 != 0); // Just verify it runs
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,8 +28,13 @@ pub(super) fn trim_history(history: &mut Vec<ChatMessage>, max_history: usize) {
|
||||
}
|
||||
|
||||
let start = if has_system { 1 } else { 0 };
|
||||
let to_remove = non_system_count - max_history;
|
||||
history.drain(start..start + to_remove);
|
||||
let mut trim_end = start + (non_system_count - max_history);
|
||||
// Never keep a leading `role=tool` at the trim boundary. Tool-message runs
|
||||
// must remain attached to their preceding assistant(tool_calls) message.
|
||||
while trim_end < history.len() && history[trim_end].role == "tool" {
|
||||
trim_end += 1;
|
||||
}
|
||||
history.drain(start..trim_end);
|
||||
}
|
||||
|
||||
pub(super) fn build_compaction_transcript(messages: &[ChatMessage]) -> String {
|
||||
@ -80,7 +85,11 @@ pub(super) async fn auto_compact_history(
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let compact_end = start + compact_count;
|
||||
let mut compact_end = start + compact_count;
|
||||
// Do not split assistant(tool_calls) -> tool runs across compaction boundary.
|
||||
while compact_end < history.len() && history[compact_end].role == "tool" {
|
||||
compact_end += 1;
|
||||
}
|
||||
let to_compact: Vec<ChatMessage> = history[start..compact_end].to_vec();
|
||||
let transcript = build_compaction_transcript(&to_compact);
|
||||
|
||||
@ -104,3 +113,97 @@ pub(super) async fn auto_compact_history(
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::{ChatRequest, ChatResponse, Provider};
|
||||
use async_trait::async_trait;
|
||||
|
||||
struct StaticSummaryProvider;
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for StaticSummaryProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
_system_prompt: Option<&str>,
|
||||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Ok("- summarized context".to_string())
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
_request: ChatRequest<'_>,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
Ok(ChatResponse {
|
||||
text: Some("- summarized context".to_string()),
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant_with_tool_call(id: &str) -> ChatMessage {
|
||||
ChatMessage::assistant(format!(
|
||||
"{{\"content\":\"\",\"tool_calls\":[{{\"id\":\"{id}\",\"name\":\"shell\",\"arguments\":\"{{}}\"}}]}}"
|
||||
))
|
||||
}
|
||||
|
||||
fn tool_result(id: &str) -> ChatMessage {
|
||||
ChatMessage::tool(format!("{{\"tool_call_id\":\"{id}\",\"content\":\"ok\"}}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_history_avoids_orphan_tool_at_boundary() {
|
||||
let mut history = vec![
|
||||
ChatMessage::user("old"),
|
||||
assistant_with_tool_call("call_1"),
|
||||
tool_result("call_1"),
|
||||
ChatMessage::user("recent"),
|
||||
];
|
||||
|
||||
trim_history(&mut history, 2);
|
||||
|
||||
assert_eq!(history.len(), 1);
|
||||
assert_eq!(history[0].role, "user");
|
||||
assert_eq!(history[0].content, "recent");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_compact_history_does_not_split_tool_run_boundary() {
|
||||
let mut history = vec![
|
||||
ChatMessage::user("oldest"),
|
||||
assistant_with_tool_call("call_2"),
|
||||
tool_result("call_2"),
|
||||
];
|
||||
for idx in 0..19 {
|
||||
history.push(ChatMessage::user(format!("recent-{idx}")));
|
||||
}
|
||||
// 22 non-system messages => compaction with max_history=21 would
|
||||
// previously cut right before the tool result (index 2).
|
||||
assert_eq!(history.len(), 22);
|
||||
|
||||
let compacted =
|
||||
auto_compact_history(&mut history, &StaticSummaryProvider, "test-model", 21)
|
||||
.await
|
||||
.expect("compaction should succeed");
|
||||
|
||||
assert!(compacted);
|
||||
assert_eq!(history[0].role, "assistant");
|
||||
assert!(
|
||||
history[0].content.contains("[Compaction summary]"),
|
||||
"summary message should replace compacted range"
|
||||
);
|
||||
assert_ne!(
|
||||
history[1].role, "tool",
|
||||
"first retained message must not be an orphan tool result"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -886,6 +886,7 @@ pub(super) fn map_tool_name_alias(tool_name: &str) -> &str {
|
||||
// Memory variations
|
||||
"memoryrecall" | "memory_recall" | "recall" | "memrecall" => "memory_recall",
|
||||
"memorystore" | "memory_store" | "store" | "memstore" => "memory_store",
|
||||
"memoryobserve" | "memory_observe" | "observe" | "memobserve" => "memory_observe",
|
||||
"memoryforget" | "memory_forget" | "forget" | "memforget" => "memory_forget",
|
||||
// HTTP variations
|
||||
"http_request" | "http" | "fetch" | "curl" | "wget" => "http_request",
|
||||
@ -1026,6 +1027,7 @@ pub(super) fn default_param_for_tool(tool: &str) -> &'static str {
|
||||
"memory_recall" | "memoryrecall" | "recall" | "memrecall" | "memory_forget"
|
||||
| "memoryforget" | "forget" | "memforget" => "query",
|
||||
"memory_store" | "memorystore" | "store" | "memstore" => "content",
|
||||
"memory_observe" | "memoryobserve" | "observe" | "memobserve" => "observation",
|
||||
// HTTP and browser tools default to "url"
|
||||
"http_request" | "http" | "fetch" | "curl" | "wget" | "browser_open" | "browser"
|
||||
| "web_search" => "url",
|
||||
|
||||
@ -5,6 +5,7 @@ pub mod dispatcher;
|
||||
pub mod loop_;
|
||||
pub mod memory_loader;
|
||||
pub mod prompt;
|
||||
pub mod quota_aware;
|
||||
pub mod research;
|
||||
pub mod session;
|
||||
|
||||
@ -14,4 +15,4 @@ mod tests;
|
||||
#[allow(unused_imports)]
|
||||
pub use agent::{Agent, AgentBuilder};
|
||||
#[allow(unused_imports)]
|
||||
pub use loop_::{process_message, run};
|
||||
pub use loop_::{process_message, process_message_with_session, run};
|
||||
|
||||
@ -496,6 +496,7 @@ mod tests {
|
||||
}],
|
||||
prompts: vec!["Run smoke tests before deploy.".into()],
|
||||
location: None,
|
||||
always: false,
|
||||
}];
|
||||
|
||||
let ctx = PromptContext {
|
||||
@ -534,6 +535,7 @@ mod tests {
|
||||
}],
|
||||
prompts: vec!["Run smoke tests before deploy.".into()],
|
||||
location: Some(Path::new("/tmp/workspace/skills/deploy/SKILL.md").to_path_buf()),
|
||||
always: false,
|
||||
}];
|
||||
|
||||
let ctx = PromptContext {
|
||||
@ -594,6 +596,7 @@ mod tests {
|
||||
}],
|
||||
prompts: vec!["Use <tool_call> and & keep output \"safe\"".into()],
|
||||
location: None,
|
||||
always: false,
|
||||
}];
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp/workspace"),
|
||||
|
||||
233
src/agent/quota_aware.rs
Normal file
233
src/agent/quota_aware.rs
Normal file
@ -0,0 +1,233 @@
|
||||
//! Quota-aware agent loop helpers.
|
||||
//!
|
||||
//! This module provides utilities for the agent loop to:
|
||||
//! - Check provider quota status before expensive operations
|
||||
//! - Warn users when quota is running low
|
||||
//! - Switch providers mid-conversation when requested via tools
|
||||
//! - Handle rate limit errors with automatic fallback
|
||||
|
||||
use crate::auth::profiles::AuthProfilesStore;
|
||||
use crate::config::Config;
|
||||
use crate::providers::health::ProviderHealthTracker;
|
||||
use crate::providers::quota_types::QuotaStatus;
|
||||
use anyhow::Result;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Check if we should warn about low quota before an operation.
|
||||
///
|
||||
/// Returns `Some(warning_message)` if quota is running low (< 10% remaining).
|
||||
pub async fn check_quota_warning(
|
||||
config: &Config,
|
||||
provider_name: &str,
|
||||
parallel_count: usize,
|
||||
) -> Result<Option<String>> {
|
||||
if parallel_count < 5 {
|
||||
// Only warn for operations with 5+ parallel calls
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let health_tracker = ProviderHealthTracker::new(
|
||||
3, // failure_threshold
|
||||
Duration::from_secs(60), // cooldown
|
||||
100, // max tracked providers
|
||||
);
|
||||
|
||||
let auth_store = AuthProfilesStore::new(&config.workspace_dir, config.secrets.encrypt);
|
||||
let profiles_data = auth_store.load().await?;
|
||||
|
||||
let summary = crate::providers::quota_cli::build_quota_summary(
|
||||
&health_tracker,
|
||||
&profiles_data,
|
||||
Some(provider_name),
|
||||
)?;
|
||||
|
||||
// Find the provider in summary
|
||||
if let Some(provider_info) = summary
|
||||
.providers
|
||||
.iter()
|
||||
.find(|p| p.provider == provider_name)
|
||||
{
|
||||
// Check circuit breaker status
|
||||
if provider_info.status == QuotaStatus::CircuitOpen {
|
||||
let reset_str = if let Some(resets_at) = provider_info.circuit_resets_at {
|
||||
format!(" (resets {})", format_relative_time(resets_at))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
return Ok(Some(format!(
|
||||
"⚠️ **Provider Unavailable**: {} is circuit-open{}. \
|
||||
Consider switching to an alternative provider using the `check_provider_quota` tool.",
|
||||
provider_name, reset_str
|
||||
)));
|
||||
}
|
||||
|
||||
// Check rate limit status
|
||||
if provider_info.status == QuotaStatus::RateLimited
|
||||
|| provider_info.status == QuotaStatus::QuotaExhausted
|
||||
{
|
||||
return Ok(Some(format!(
|
||||
"⚠️ **Rate Limit Warning**: {} is rate-limited. \
|
||||
Your parallel operation ({} calls) may fail. \
|
||||
Consider switching to another provider using `check_provider_quota` and `switch_provider` tools.",
|
||||
provider_name, parallel_count
|
||||
)));
|
||||
}
|
||||
|
||||
// Check individual profile quotas
|
||||
for profile in &provider_info.profiles {
|
||||
if let (Some(remaining), Some(total)) =
|
||||
(profile.rate_limit_remaining, profile.rate_limit_total)
|
||||
{
|
||||
let quota_pct = (remaining as f64 / total as f64) * 100.0;
|
||||
if quota_pct < 10.0 && remaining < parallel_count as u64 {
|
||||
let reset_str = if let Some(reset_at) = profile.rate_limit_reset_at {
|
||||
format!(" (resets {})", format_relative_time(reset_at))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
return Ok(Some(format!(
|
||||
"⚠️ **Low Quota Warning**: {} profile '{}' has only {}/{} requests remaining ({:.0}%){}. \
|
||||
Your operation requires {} calls. \
|
||||
Consider: (1) reducing parallel operations, (2) switching providers, or (3) waiting for quota reset.",
|
||||
provider_name,
|
||||
profile.profile_name,
|
||||
remaining,
|
||||
total,
|
||||
quota_pct,
|
||||
reset_str,
|
||||
parallel_count
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Parse switch_provider metadata from tool result output.
|
||||
///
|
||||
/// The `switch_provider` tool embeds JSON metadata in its output as:
|
||||
/// `<!-- metadata: {...} -->`
|
||||
///
|
||||
/// Returns `Some((provider, model))` if a provider switch was requested.
|
||||
pub fn parse_switch_provider_metadata(tool_output: &str) -> Option<(String, Option<String>)> {
|
||||
// Look for <!-- metadata: {...} --> pattern
|
||||
if let Some(start) = tool_output.find("<!-- metadata:") {
|
||||
if let Some(end) = tool_output[start..].find("-->") {
|
||||
let json_str = &tool_output[start + 14..start + end].trim();
|
||||
if let Ok(metadata) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
if metadata.get("action").and_then(|v| v.as_str()) == Some("switch_provider") {
|
||||
let provider = metadata
|
||||
.get("provider")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let model = metadata
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
if let Some(p) = provider {
|
||||
return Some((p, model));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Format relative time (e.g., "in 2h 30m" or "5 minutes ago").
|
||||
fn format_relative_time(dt: chrono::DateTime<chrono::Utc>) -> String {
|
||||
let now = chrono::Utc::now();
|
||||
let diff = dt.signed_duration_since(now);
|
||||
|
||||
if diff.num_seconds() < 0 {
|
||||
// In the past
|
||||
let abs_diff = -diff;
|
||||
if abs_diff.num_hours() > 0 {
|
||||
format!("{}h ago", abs_diff.num_hours())
|
||||
} else if abs_diff.num_minutes() > 0 {
|
||||
format!("{}m ago", abs_diff.num_minutes())
|
||||
} else {
|
||||
format!("{}s ago", abs_diff.num_seconds())
|
||||
}
|
||||
} else {
|
||||
// In the future
|
||||
if diff.num_hours() > 0 {
|
||||
format!("in {}h {}m", diff.num_hours(), diff.num_minutes() % 60)
|
||||
} else if diff.num_minutes() > 0 {
|
||||
format!("in {}m", diff.num_minutes())
|
||||
} else {
|
||||
format!("in {}s", diff.num_seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an available alternative provider when current provider is unavailable.
|
||||
///
|
||||
/// Returns the name of a healthy provider with available quota, or None if all are unavailable.
|
||||
pub async fn find_available_provider(
|
||||
config: &Config,
|
||||
current_provider: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let health_tracker = ProviderHealthTracker::new(
|
||||
3, // failure_threshold
|
||||
Duration::from_secs(60), // cooldown
|
||||
100, // max tracked providers
|
||||
);
|
||||
|
||||
let auth_store = AuthProfilesStore::new(&config.workspace_dir, config.secrets.encrypt);
|
||||
let profiles_data = auth_store.load().await?;
|
||||
|
||||
let summary =
|
||||
crate::providers::quota_cli::build_quota_summary(&health_tracker, &profiles_data, None)?;
|
||||
|
||||
// Find providers with Ok status (not current provider)
|
||||
for provider_info in &summary.providers {
|
||||
if provider_info.provider != current_provider && provider_info.status == QuotaStatus::Ok {
|
||||
return Ok(Some(provider_info.provider.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_switch_provider_metadata() {
|
||||
let output = "Switching to gemini.\n\n<!-- metadata: {\"action\":\"switch_provider\",\"provider\":\"gemini\",\"model\":null,\"reason\":\"user request\"} -->";
|
||||
let result = parse_switch_provider_metadata(output);
|
||||
assert_eq!(result, Some(("gemini".to_string(), None)));
|
||||
|
||||
let output_with_model = "Switching to openai.\n\n<!-- metadata: {\"action\":\"switch_provider\",\"provider\":\"openai\",\"model\":\"gpt-4\",\"reason\":\"rate limit\"} -->";
|
||||
let result = parse_switch_provider_metadata(output_with_model);
|
||||
assert_eq!(
|
||||
result,
|
||||
Some(("openai".to_string(), Some("gpt-4".to_string())))
|
||||
);
|
||||
|
||||
let no_metadata = "Just some regular tool output";
|
||||
assert_eq!(parse_switch_provider_metadata(no_metadata), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_relative_time() {
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
let future = Utc::now() + Duration::seconds(3700);
|
||||
let formatted = format_relative_time(future);
|
||||
assert!(formatted.contains("in"));
|
||||
assert!(formatted.contains('h'));
|
||||
|
||||
let past = Utc::now() - Duration::seconds(300);
|
||||
let formatted = format_relative_time(past);
|
||||
assert!(formatted.contains("ago"));
|
||||
}
|
||||
}
|
||||
@ -95,6 +95,7 @@ impl Provider for ScriptedProvider {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
Ok(guard.remove(0))
|
||||
@ -332,6 +333,7 @@ fn tool_response(calls: Vec<ToolCall>) -> ChatResponse {
|
||||
tool_calls: calls,
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -342,6 +344,7 @@ fn text_response(text: &str) -> ChatResponse {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -354,6 +357,7 @@ fn xml_tool_response(name: &str, args: &str) -> ChatResponse {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -744,6 +748,7 @@ async fn turn_handles_empty_text_response() {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}]));
|
||||
|
||||
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
|
||||
@ -759,6 +764,7 @@ async fn turn_handles_none_text_response() {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}]));
|
||||
|
||||
let mut agent = build_agent_with(provider, vec![], Box::new(NativeToolDispatcher));
|
||||
@ -784,6 +790,7 @@ async fn turn_preserves_text_alongside_tool_calls() {
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
},
|
||||
text_response("Here are the results"),
|
||||
]));
|
||||
@ -1022,6 +1029,7 @@ async fn native_dispatcher_handles_stringified_arguments() {
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
let (_, calls) = dispatcher.parse_response(&response);
|
||||
@ -1049,6 +1057,7 @@ fn xml_dispatcher_handles_nested_json() {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
@ -1068,6 +1077,7 @@ fn xml_dispatcher_handles_empty_tool_call_tag() {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
@ -1083,6 +1093,7 @@ fn xml_dispatcher_handles_unclosed_tool_call() {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
|
||||
let dispatcher = XmlToolDispatcher;
|
||||
|
||||
@ -132,9 +132,11 @@ fn normalize_group_reply_allowed_sender_ids(sender_ids: Vec<String>) -> Vec<Stri
|
||||
/// Process Discord message attachments and return a string to append to the
|
||||
/// agent message context.
|
||||
///
|
||||
/// `text/*` MIME types are fetched and inlined, while `image/*` MIME types are
|
||||
/// forwarded as `[IMAGE:<url>]` markers. Other types are skipped. Fetch errors
|
||||
/// are logged as warnings.
|
||||
/// `image/*` attachments are forwarded as `[IMAGE:<url>]` markers. For
|
||||
/// `application/octet-stream` or missing MIME types, image-like filename/url
|
||||
/// extensions are also treated as images.
|
||||
/// `text/*` MIME types are fetched and inlined. Other types are skipped.
|
||||
/// Fetch errors are logged as warnings.
|
||||
async fn process_attachments(
|
||||
attachments: &[serde_json::Value],
|
||||
client: &reqwest::Client,
|
||||
@ -153,7 +155,9 @@ async fn process_attachments(
|
||||
tracing::warn!(name, "discord: attachment has no url, skipping");
|
||||
continue;
|
||||
};
|
||||
if ct.starts_with("text/") {
|
||||
if is_image_attachment(ct, name, url) {
|
||||
parts.push(format!("[IMAGE:{url}]"));
|
||||
} else if ct.starts_with("text/") {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
if let Ok(text) = resp.text().await {
|
||||
@ -167,8 +171,6 @@ async fn process_attachments(
|
||||
tracing::warn!(name, error = %e, "discord attachment fetch error");
|
||||
}
|
||||
}
|
||||
} else if ct.starts_with("image/") {
|
||||
parts.push(format!("[IMAGE:{url}]"));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
name,
|
||||
@ -180,6 +182,54 @@ async fn process_attachments(
|
||||
parts.join("\n---\n")
|
||||
}
|
||||
|
||||
fn is_image_attachment(content_type: &str, filename: &str, url: &str) -> bool {
|
||||
let normalized_content_type = content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if !normalized_content_type.is_empty() {
|
||||
if normalized_content_type.starts_with("image/") {
|
||||
return true;
|
||||
}
|
||||
// Trust explicit non-image MIME to avoid false positives from filename extensions.
|
||||
if normalized_content_type != "application/octet-stream" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
has_image_extension(filename) || has_image_extension(url)
|
||||
}
|
||||
|
||||
fn has_image_extension(value: &str) -> bool {
|
||||
let base = value.split('?').next().unwrap_or(value);
|
||||
let base = base.split('#').next().unwrap_or(base);
|
||||
let ext = Path::new(base)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase());
|
||||
|
||||
matches!(
|
||||
ext.as_deref(),
|
||||
Some(
|
||||
"png"
|
||||
| "jpg"
|
||||
| "jpeg"
|
||||
| "gif"
|
||||
| "webp"
|
||||
| "bmp"
|
||||
| "tif"
|
||||
| "tiff"
|
||||
| "svg"
|
||||
| "avif"
|
||||
| "heic"
|
||||
| "heif"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum DiscordAttachmentKind {
|
||||
Image,
|
||||
@ -1561,8 +1611,7 @@ mod tests {
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_attachments_emits_single_image_marker() {
|
||||
async fn process_attachments_emits_image_marker_for_image_content_type() {
|
||||
let client = reqwest::Client::new();
|
||||
let attachments = vec![serde_json::json!({
|
||||
"url": "https://cdn.discordapp.com/attachments/123/456/photo.png",
|
||||
@ -1598,6 +1647,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_attachments_emits_image_marker_from_filename_without_content_type() {
|
||||
let client = reqwest::Client::new();
|
||||
let attachments = vec![serde_json::json!({
|
||||
"url": "https://cdn.discordapp.com/attachments/123/456/photo.jpeg?size=1024",
|
||||
"filename": "photo.jpeg"
|
||||
})];
|
||||
let result = process_attachments(&attachments, &client).await;
|
||||
assert_eq!(
|
||||
result,
|
||||
"[IMAGE:https://cdn.discordapp.com/attachments/123/456/photo.jpeg?size=1024]"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_image_attachment_prefers_non_image_content_type_over_extension() {
|
||||
assert!(!is_image_attachment(
|
||||
"text/plain",
|
||||
"photo.png",
|
||||
"https://cdn.discordapp.com/attachments/123/456/photo.png"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_image_attachment_allows_octet_stream_extension_fallback() {
|
||||
assert!(is_image_attachment(
|
||||
"application/octet-stream",
|
||||
"photo.png",
|
||||
"https://cdn.discordapp.com/attachments/123/456/photo.png"
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn parse_attachment_markers_extracts_supported_markers() {
|
||||
let input = "Report\n[IMAGE:https://example.com/a.png]\n[DOCUMENT:/tmp/a.pdf]";
|
||||
|
||||
@ -67,6 +67,37 @@ pub struct EmailConfig {
|
||||
/// Allowed sender addresses/domains (empty = deny all, ["*"] = allow all)
|
||||
#[serde(default)]
|
||||
pub allowed_senders: Vec<String>,
|
||||
/// Optional IMAP ID extension (RFC 2971) client identification.
|
||||
#[serde(default)]
|
||||
pub imap_id: EmailImapIdConfig,
|
||||
}
|
||||
|
||||
/// IMAP ID extension metadata (RFC 2971)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct EmailImapIdConfig {
|
||||
/// Send IMAP `ID` command after login (recommended for some providers such as NetEase).
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
/// Client application name
|
||||
#[serde(default = "default_imap_id_name")]
|
||||
pub name: String,
|
||||
/// Client application version
|
||||
#[serde(default = "default_imap_id_version")]
|
||||
pub version: String,
|
||||
/// Client vendor name
|
||||
#[serde(default = "default_imap_id_vendor")]
|
||||
pub vendor: String,
|
||||
}
|
||||
|
||||
impl Default for EmailImapIdConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_true(),
|
||||
name: default_imap_id_name(),
|
||||
version: default_imap_id_version(),
|
||||
vendor: default_imap_id_vendor(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::config::traits::ChannelConfig for EmailConfig {
|
||||
@ -93,6 +124,15 @@ fn default_idle_timeout() -> u64 {
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_imap_id_name() -> String {
|
||||
"zeroclaw".into()
|
||||
}
|
||||
fn default_imap_id_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").into()
|
||||
}
|
||||
fn default_imap_id_vendor() -> String {
|
||||
"zeroclaw-labs".into()
|
||||
}
|
||||
|
||||
impl Default for EmailConfig {
|
||||
fn default() -> Self {
|
||||
@ -108,6 +148,7 @@ impl Default for EmailConfig {
|
||||
from_address: String::new(),
|
||||
idle_timeout_secs: default_idle_timeout(),
|
||||
allowed_senders: Vec::new(),
|
||||
imap_id: EmailImapIdConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -228,15 +269,54 @@ impl EmailChannel {
|
||||
let client = async_imap::Client::new(stream);
|
||||
|
||||
// Login
|
||||
let session = client
|
||||
let mut session = client
|
||||
.login(&self.config.username, &self.config.password)
|
||||
.await
|
||||
.map_err(|(e, _)| anyhow!("IMAP login failed: {}", e))?;
|
||||
|
||||
debug!("IMAP login successful");
|
||||
self.send_imap_id(&mut session).await;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Send RFC 2971 IMAP ID extension metadata.
|
||||
/// Any ID errors are non-fatal to keep compatibility with providers
|
||||
/// that do not support the extension.
|
||||
async fn send_imap_id(&self, session: &mut ImapSession) {
|
||||
if !self.config.imap_id.enabled {
|
||||
debug!("IMAP ID extension disabled by configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
let name = self.config.imap_id.name.trim();
|
||||
let version = self.config.imap_id.version.trim();
|
||||
let vendor = self.config.imap_id.vendor.trim();
|
||||
|
||||
let mut identification: Vec<(&str, Option<&str>)> = Vec::new();
|
||||
if !name.is_empty() {
|
||||
identification.push(("name", Some(name)));
|
||||
}
|
||||
if !version.is_empty() {
|
||||
identification.push(("version", Some(version)));
|
||||
}
|
||||
if !vendor.is_empty() {
|
||||
identification.push(("vendor", Some(vendor)));
|
||||
}
|
||||
|
||||
if identification.is_empty() {
|
||||
debug!("IMAP ID extension enabled but no identification fields configured");
|
||||
return;
|
||||
}
|
||||
|
||||
match session.id(identification).await {
|
||||
Ok(_) => debug!("IMAP ID extension sent successfully"),
|
||||
Err(err) => warn!(
|
||||
"IMAP ID extension failed (continuing without ID metadata): {}",
|
||||
err
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch and process unseen messages from the selected mailbox
|
||||
async fn fetch_unseen(&self, session: &mut ImapSession) -> Result<Vec<ParsedEmail>> {
|
||||
// Search for unseen messages
|
||||
@ -619,6 +699,10 @@ mod tests {
|
||||
assert_eq!(config.from_address, "");
|
||||
assert_eq!(config.idle_timeout_secs, 1740);
|
||||
assert!(config.allowed_senders.is_empty());
|
||||
assert!(config.imap_id.enabled);
|
||||
assert_eq!(config.imap_id.name, "zeroclaw");
|
||||
assert_eq!(config.imap_id.version, env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(config.imap_id.vendor, "zeroclaw-labs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -635,6 +719,7 @@ mod tests {
|
||||
from_address: "bot@example.com".to_string(),
|
||||
idle_timeout_secs: 1200,
|
||||
allowed_senders: vec!["allowed@example.com".to_string()],
|
||||
imap_id: EmailImapIdConfig::default(),
|
||||
};
|
||||
assert_eq!(config.imap_host, "imap.example.com");
|
||||
assert_eq!(config.imap_folder, "Archive");
|
||||
@ -655,6 +740,7 @@ mod tests {
|
||||
from_address: "bot@test.com".to_string(),
|
||||
idle_timeout_secs: 1740,
|
||||
allowed_senders: vec!["*".to_string()],
|
||||
imap_id: EmailImapIdConfig::default(),
|
||||
};
|
||||
let cloned = config.clone();
|
||||
assert_eq!(cloned.imap_host, config.imap_host);
|
||||
@ -900,6 +986,7 @@ mod tests {
|
||||
from_address: "bot@example.com".to_string(),
|
||||
idle_timeout_secs: 1740,
|
||||
allowed_senders: vec!["allowed@example.com".to_string()],
|
||||
imap_id: EmailImapIdConfig::default(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&config).unwrap();
|
||||
@ -925,6 +1012,8 @@ mod tests {
|
||||
assert_eq!(config.smtp_port, 465); // default
|
||||
assert!(config.smtp_tls); // default
|
||||
assert_eq!(config.idle_timeout_secs, 1740); // default
|
||||
assert!(config.imap_id.enabled); // default
|
||||
assert_eq!(config.imap_id.name, "zeroclaw"); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -965,6 +1054,45 @@ mod tests {
|
||||
assert_eq!(channel.config.idle_timeout_secs, 600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imap_id_defaults_deserialize_when_omitted() {
|
||||
let json = r#"{
|
||||
"imap_host": "imap.test.com",
|
||||
"smtp_host": "smtp.test.com",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"from_address": "bot@test.com"
|
||||
}"#;
|
||||
|
||||
let config: EmailConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(config.imap_id.enabled);
|
||||
assert_eq!(config.imap_id.name, "zeroclaw");
|
||||
assert_eq!(config.imap_id.vendor, "zeroclaw-labs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imap_id_custom_values_deserialize() {
|
||||
let json = r#"{
|
||||
"imap_host": "imap.test.com",
|
||||
"smtp_host": "smtp.test.com",
|
||||
"username": "user",
|
||||
"password": "pass",
|
||||
"from_address": "bot@test.com",
|
||||
"imap_id": {
|
||||
"enabled": false,
|
||||
"name": "custom-client",
|
||||
"version": "9.9.9",
|
||||
"vendor": "custom-vendor"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let config: EmailConfig = serde_json::from_str(json).unwrap();
|
||||
assert!(!config.imap_id.enabled);
|
||||
assert_eq!(config.imap_id.name, "custom-client");
|
||||
assert_eq!(config.imap_id.version, "9.9.9");
|
||||
assert_eq!(config.imap_id.vendor, "custom-vendor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_config_debug_output() {
|
||||
let config = EmailConfig {
|
||||
|
||||
637
src/channels/github.rs
Normal file
637
src/channels/github.rs
Normal file
@ -0,0 +1,637 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use async_trait::async_trait;
|
||||
use hmac::{Hmac, Mac};
|
||||
use reqwest::{header::HeaderMap, StatusCode};
|
||||
use sha2::Sha256;
|
||||
use std::time::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_GITHUB_API_BASE: &str = "https://api.github.com";
|
||||
const GITHUB_API_VERSION: &str = "2022-11-28";
|
||||
|
||||
/// GitHub channel in webhook mode.
|
||||
///
|
||||
/// Incoming events are received by the gateway endpoint `/github`.
|
||||
/// Outbound replies are posted as issue/PR comments via GitHub REST API.
|
||||
pub struct GitHubChannel {
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
allowed_repos: Vec<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GitHubChannel {
|
||||
pub fn new(
|
||||
access_token: String,
|
||||
api_base_url: Option<String>,
|
||||
allowed_repos: Vec<String>,
|
||||
) -> Self {
|
||||
let base = api_base_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or(DEFAULT_GITHUB_API_BASE);
|
||||
Self {
|
||||
access_token,
|
||||
api_base_url: base.trim_end_matches('/').to_string(),
|
||||
allowed_repos,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn parse_rfc3339_timestamp(raw: Option<&str>) -> u64 {
|
||||
raw.and_then(|value| {
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp().max(0) as u64)
|
||||
})
|
||||
.unwrap_or_else(Self::now_unix_secs)
|
||||
}
|
||||
|
||||
fn repo_is_allowed(&self, repo_full_name: &str) -> bool {
|
||||
if self.allowed_repos.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.allowed_repos.iter().any(|raw| {
|
||||
let allowed = raw.trim();
|
||||
if allowed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if allowed == "*" {
|
||||
return true;
|
||||
}
|
||||
if let Some(owner_prefix) = allowed.strip_suffix("/*") {
|
||||
if let Some((repo_owner, _)) = repo_full_name.split_once('/') {
|
||||
return repo_owner.eq_ignore_ascii_case(owner_prefix);
|
||||
}
|
||||
}
|
||||
repo_full_name.eq_ignore_ascii_case(allowed)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_issue_recipient(recipient: &str) -> Option<(&str, u64)> {
|
||||
let (repo, issue_no) = recipient.trim().rsplit_once('#')?;
|
||||
if !repo.contains('/') {
|
||||
return None;
|
||||
}
|
||||
let number = issue_no.parse::<u64>().ok()?;
|
||||
if number == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((repo, number))
|
||||
}
|
||||
|
||||
fn issue_comment_api_url(&self, repo_full_name: &str, issue_number: u64) -> Option<String> {
|
||||
let (owner, repo) = repo_full_name.split_once('/')?;
|
||||
let owner = urlencoding::encode(owner.trim());
|
||||
let repo = urlencoding::encode(repo.trim());
|
||||
Some(format!(
|
||||
"{}/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
||||
self.api_base_url
|
||||
))
|
||||
}
|
||||
|
||||
fn is_rate_limited(status: StatusCode, headers: &HeaderMap) -> bool {
|
||||
if status == StatusCode::TOO_MANY_REQUESTS {
|
||||
return true;
|
||||
}
|
||||
status == StatusCode::FORBIDDEN
|
||||
&& headers
|
||||
.get("x-ratelimit-remaining")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.is_some_and(|v| v == "0")
|
||||
}
|
||||
|
||||
fn retry_delay_from_headers(headers: &HeaderMap) -> Option<Duration> {
|
||||
if let Some(raw) = headers.get("retry-after").and_then(|v| v.to_str().ok()) {
|
||||
if let Ok(secs) = raw.trim().parse::<u64>() {
|
||||
return Some(Duration::from_secs(secs.max(1).min(60)));
|
||||
}
|
||||
}
|
||||
|
||||
let remaining_is_zero = headers
|
||||
.get("x-ratelimit-remaining")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.is_some_and(|v| v == "0");
|
||||
if !remaining_is_zero {
|
||||
return None;
|
||||
}
|
||||
|
||||
let reset = headers
|
||||
.get("x-ratelimit-reset")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.trim().parse::<u64>().ok())?;
|
||||
let now = Self::now_unix_secs();
|
||||
let wait = if reset > now { reset - now } else { 1 };
|
||||
Some(Duration::from_secs(wait.max(1).min(60)))
|
||||
}
|
||||
|
||||
async fn post_issue_comment(
|
||||
&self,
|
||||
repo_full_name: &str,
|
||||
issue_number: u64,
|
||||
body: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(url) = self.issue_comment_api_url(repo_full_name, issue_number) else {
|
||||
anyhow::bail!("invalid GitHub recipient repo format: {repo_full_name}");
|
||||
};
|
||||
|
||||
let payload = serde_json::json!({ "body": body });
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
|
||||
for attempt in 1..=3 {
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", GITHUB_API_VERSION)
|
||||
.header("User-Agent", "ZeroClaw-GitHub-Channel")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let body_text = response.text().await.unwrap_or_default();
|
||||
let sanitized = crate::providers::sanitize_api_error(&body_text);
|
||||
|
||||
if attempt < 3 && Self::is_rate_limited(status, &headers) {
|
||||
let wait = Self::retry_delay_from_headers(&headers).unwrap_or(backoff);
|
||||
tracing::warn!(
|
||||
"GitHub send rate-limited (status {status}), retrying in {}s (attempt {attempt}/3)",
|
||||
wait.as_secs()
|
||||
);
|
||||
tokio::time::sleep(wait).await;
|
||||
backoff = (backoff * 2).min(Duration::from_secs(8));
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::error!("GitHub comment post failed: {status} — {sanitized}");
|
||||
anyhow::bail!("GitHub API error: {status}");
|
||||
}
|
||||
|
||||
anyhow::bail!("GitHub send retries exhausted")
|
||||
}
|
||||
|
||||
fn is_bot_actor(login: Option<&str>, actor_type: Option<&str>) -> bool {
|
||||
actor_type
|
||||
.map(|v| v.eq_ignore_ascii_case("bot"))
|
||||
.unwrap_or(false)
|
||||
|| login
|
||||
.map(|v| v.trim_end().ends_with("[bot]"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn parse_issue_comment_event(
|
||||
&self,
|
||||
payload: &serde_json::Value,
|
||||
event_name: &str,
|
||||
) -> Vec<ChannelMessage> {
|
||||
let mut out = Vec::new();
|
||||
let action = payload
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if action != "created" {
|
||||
return out;
|
||||
}
|
||||
|
||||
let repo = payload
|
||||
.get("repository")
|
||||
.and_then(|v| v.get("full_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(repo) = repo else {
|
||||
return out;
|
||||
};
|
||||
|
||||
if !self.repo_is_allowed(repo) {
|
||||
tracing::warn!(
|
||||
"GitHub: ignoring webhook for unauthorized repository '{repo}'. \
|
||||
Add repo to channels_config.github.allowed_repos or use '*' explicitly."
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
let comment = payload.get("comment");
|
||||
let comment_body = comment
|
||||
.and_then(|v| v.get("body"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(comment_body) = comment_body else {
|
||||
return out;
|
||||
};
|
||||
|
||||
let actor_login = comment
|
||||
.and_then(|v| v.get("user"))
|
||||
.and_then(|v| v.get("login"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("sender")
|
||||
.and_then(|v| v.get("login"))
|
||||
.and_then(|v| v.as_str())
|
||||
});
|
||||
let actor_type = comment
|
||||
.and_then(|v| v.get("user"))
|
||||
.and_then(|v| v.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("sender")
|
||||
.and_then(|v| v.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
});
|
||||
|
||||
if Self::is_bot_actor(actor_login, actor_type) {
|
||||
return out;
|
||||
}
|
||||
|
||||
let issue_number = payload
|
||||
.get("issue")
|
||||
.and_then(|v| v.get("number"))
|
||||
.and_then(|v| v.as_u64());
|
||||
let Some(issue_number) = issue_number else {
|
||||
return out;
|
||||
};
|
||||
|
||||
let issue_title = payload
|
||||
.get("issue")
|
||||
.and_then(|v| v.get("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let comment_url = comment
|
||||
.and_then(|v| v.get("html_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let timestamp = Self::parse_rfc3339_timestamp(
|
||||
comment
|
||||
.and_then(|v| v.get("created_at"))
|
||||
.and_then(|v| v.as_str()),
|
||||
);
|
||||
let comment_id = comment
|
||||
.and_then(|v| v.get("id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.to_string());
|
||||
|
||||
let sender = actor_login.unwrap_or("unknown");
|
||||
let content = format!(
|
||||
"[GitHub {event_name}] repo={repo} issue=#{issue_number} title=\"{issue_title}\"\n\
|
||||
author={sender}\nurl={comment_url}\n\n{comment_body}"
|
||||
);
|
||||
|
||||
out.push(ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: sender.to_string(),
|
||||
reply_target: format!("{repo}#{issue_number}"),
|
||||
content,
|
||||
channel: "github".to_string(),
|
||||
timestamp,
|
||||
thread_ts: comment_id,
|
||||
});
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_pr_review_comment_event(&self, payload: &serde_json::Value) -> Vec<ChannelMessage> {
|
||||
let mut out = Vec::new();
|
||||
let action = payload
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
if action != "created" {
|
||||
return out;
|
||||
}
|
||||
|
||||
let repo = payload
|
||||
.get("repository")
|
||||
.and_then(|v| v.get("full_name"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(repo) = repo else {
|
||||
return out;
|
||||
};
|
||||
|
||||
if !self.repo_is_allowed(repo) {
|
||||
tracing::warn!(
|
||||
"GitHub: ignoring webhook for unauthorized repository '{repo}'. \
|
||||
Add repo to channels_config.github.allowed_repos or use '*' explicitly."
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
let comment = payload.get("comment");
|
||||
let comment_body = comment
|
||||
.and_then(|v| v.get("body"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(comment_body) = comment_body else {
|
||||
return out;
|
||||
};
|
||||
|
||||
let actor_login = comment
|
||||
.and_then(|v| v.get("user"))
|
||||
.and_then(|v| v.get("login"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("sender")
|
||||
.and_then(|v| v.get("login"))
|
||||
.and_then(|v| v.as_str())
|
||||
});
|
||||
let actor_type = comment
|
||||
.and_then(|v| v.get("user"))
|
||||
.and_then(|v| v.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| {
|
||||
payload
|
||||
.get("sender")
|
||||
.and_then(|v| v.get("type"))
|
||||
.and_then(|v| v.as_str())
|
||||
});
|
||||
|
||||
if Self::is_bot_actor(actor_login, actor_type) {
|
||||
return out;
|
||||
}
|
||||
|
||||
let pr_number = payload
|
||||
.get("pull_request")
|
||||
.and_then(|v| v.get("number"))
|
||||
.and_then(|v| v.as_u64());
|
||||
let Some(pr_number) = pr_number else {
|
||||
return out;
|
||||
};
|
||||
|
||||
let pr_title = payload
|
||||
.get("pull_request")
|
||||
.and_then(|v| v.get("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let comment_url = comment
|
||||
.and_then(|v| v.get("html_url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let file_path = comment
|
||||
.and_then(|v| v.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default();
|
||||
let timestamp = Self::parse_rfc3339_timestamp(
|
||||
comment
|
||||
.and_then(|v| v.get("created_at"))
|
||||
.and_then(|v| v.as_str()),
|
||||
);
|
||||
let comment_id = comment
|
||||
.and_then(|v| v.get("id"))
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v.to_string());
|
||||
|
||||
let sender = actor_login.unwrap_or("unknown");
|
||||
let content = format!(
|
||||
"[GitHub pull_request_review_comment] repo={repo} pr=#{pr_number} title=\"{pr_title}\"\n\
|
||||
author={sender}\nfile={file_path}\nurl={comment_url}\n\n{comment_body}"
|
||||
);
|
||||
|
||||
out.push(ChannelMessage {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
sender: sender.to_string(),
|
||||
reply_target: format!("{repo}#{pr_number}"),
|
||||
content,
|
||||
channel: "github".to_string(),
|
||||
timestamp,
|
||||
thread_ts: comment_id,
|
||||
});
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn parse_webhook_payload(
|
||||
&self,
|
||||
event_name: &str,
|
||||
payload: &serde_json::Value,
|
||||
) -> Vec<ChannelMessage> {
|
||||
match event_name {
|
||||
"issue_comment" => self.parse_issue_comment_event(payload, event_name),
|
||||
"pull_request_review_comment" => self.parse_pr_review_comment_event(payload),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for GitHubChannel {
|
||||
fn name(&self) -> &str {
|
||||
"github"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||
let Some((repo, issue_number)) = Self::parse_issue_recipient(&message.recipient) else {
|
||||
anyhow::bail!(
|
||||
"GitHub recipient must be in 'owner/repo#number' format, got '{}'",
|
||||
message.recipient
|
||||
);
|
||||
};
|
||||
|
||||
if !self.repo_is_allowed(repo) {
|
||||
anyhow::bail!("GitHub repository '{repo}' is not in allowed_repos");
|
||||
}
|
||||
|
||||
self.post_issue_comment(repo, issue_number, &message.content)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn listen(&self, _tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
|
||||
tracing::info!(
|
||||
"GitHub channel active (webhook mode). \
|
||||
Configure GitHub webhook to POST to your gateway's /github endpoint."
|
||||
);
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(3600)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let url = format!("{}/rate_limit", self.api_base_url);
|
||||
self.client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.access_token)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("X-GitHub-Api-Version", GITHUB_API_VERSION)
|
||||
.header("User-Agent", "ZeroClaw-GitHub-Channel")
|
||||
.send()
|
||||
.await
|
||||
.map(|resp| resp.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify a GitHub webhook signature from `X-Hub-Signature-256`.
|
||||
///
|
||||
/// GitHub sends signatures as `sha256=<hex_hmac>` over the raw request body.
|
||||
pub fn verify_github_signature(secret: &str, body: &[u8], signature_header: &str) -> bool {
|
||||
let signature_hex = signature_header
|
||||
.trim()
|
||||
.strip_prefix("sha256=")
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if signature_hex.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Ok(expected) = hex::decode(signature_hex) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
mac.update(body);
|
||||
mac.verify_slice(&expected).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_channel() -> GitHubChannel {
|
||||
GitHubChannel::new(
|
||||
"ghp_test".to_string(),
|
||||
None,
|
||||
vec!["zeroclaw-labs/zeroclaw".to_string()],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_channel_name() {
|
||||
let ch = make_channel();
|
||||
assert_eq!(ch.name(), "github");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_github_signature_valid() {
|
||||
let secret = "test_secret";
|
||||
let body = br#"{"action":"created"}"#;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
|
||||
assert!(verify_github_signature(secret, body, &signature));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_github_signature_rejects_invalid() {
|
||||
assert!(!verify_github_signature("secret", b"{}", "sha256=deadbeef"));
|
||||
assert!(!verify_github_signature("secret", b"{}", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_issue_comment_event_created() {
|
||||
let ch = make_channel();
|
||||
let payload = serde_json::json!({
|
||||
"action": "created",
|
||||
"repository": { "full_name": "zeroclaw-labs/zeroclaw" },
|
||||
"issue": { "number": 2079, "title": "GitHub as a native channel" },
|
||||
"comment": {
|
||||
"id": 12345,
|
||||
"body": "please add this",
|
||||
"created_at": "2026-02-27T14:00:00Z",
|
||||
"html_url": "https://github.com/zeroclaw-labs/zeroclaw/issues/2079#issuecomment-12345",
|
||||
"user": { "login": "alice", "type": "User" }
|
||||
}
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload("issue_comment", &payload);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].reply_target, "zeroclaw-labs/zeroclaw#2079");
|
||||
assert_eq!(msgs[0].sender, "alice");
|
||||
assert_eq!(msgs[0].thread_ts.as_deref(), Some("12345"));
|
||||
assert!(msgs[0].content.contains("please add this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_issue_comment_event_skips_bot_actor() {
|
||||
let ch = make_channel();
|
||||
let payload = serde_json::json!({
|
||||
"action": "created",
|
||||
"repository": { "full_name": "zeroclaw-labs/zeroclaw" },
|
||||
"issue": { "number": 1, "title": "x" },
|
||||
"comment": {
|
||||
"id": 3,
|
||||
"body": "bot note",
|
||||
"user": { "login": "zeroclaw-bot[bot]", "type": "Bot" }
|
||||
}
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload("issue_comment", &payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_issue_comment_event_blocks_unallowed_repo() {
|
||||
let ch = make_channel();
|
||||
let payload = serde_json::json!({
|
||||
"action": "created",
|
||||
"repository": { "full_name": "other/repo" },
|
||||
"issue": { "number": 1, "title": "x" },
|
||||
"comment": { "body": "hello", "user": { "login": "alice", "type": "User" } }
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload("issue_comment", &payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_pr_review_comment_event_created() {
|
||||
let ch = make_channel();
|
||||
let payload = serde_json::json!({
|
||||
"action": "created",
|
||||
"repository": { "full_name": "zeroclaw-labs/zeroclaw" },
|
||||
"pull_request": { "number": 2118, "title": "Add github channel" },
|
||||
"comment": {
|
||||
"id": 9001,
|
||||
"body": "nit: rename this variable",
|
||||
"path": "src/channels/github.rs",
|
||||
"created_at": "2026-02-27T14:00:00Z",
|
||||
"html_url": "https://github.com/zeroclaw-labs/zeroclaw/pull/2118#discussion_r9001",
|
||||
"user": { "login": "bob", "type": "User" }
|
||||
}
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload("pull_request_review_comment", &payload);
|
||||
assert_eq!(msgs.len(), 1);
|
||||
assert_eq!(msgs[0].reply_target, "zeroclaw-labs/zeroclaw#2118");
|
||||
assert_eq!(msgs[0].sender, "bob");
|
||||
assert!(msgs[0].content.contains("nit: rename this variable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_issue_recipient_format() {
|
||||
assert_eq!(
|
||||
GitHubChannel::parse_issue_recipient("zeroclaw-labs/zeroclaw#12"),
|
||||
Some(("zeroclaw-labs/zeroclaw", 12))
|
||||
);
|
||||
assert!(GitHubChannel::parse_issue_recipient("bad").is_none());
|
||||
assert!(GitHubChannel::parse_issue_recipient("owner/repo#0").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_supports_wildcards() {
|
||||
let ch = GitHubChannel::new("t".into(), None, vec!["zeroclaw-labs/*".into()]);
|
||||
assert!(ch.repo_is_allowed("zeroclaw-labs/zeroclaw"));
|
||||
assert!(!ch.repo_is_allowed("other/repo"));
|
||||
let all = GitHubChannel::new("t".into(), None, vec!["*".into()]);
|
||||
assert!(all.repo_is_allowed("anything/repo"));
|
||||
}
|
||||
}
|
||||
@ -174,7 +174,6 @@ struct LarkEvent {
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct LarkEventHeader {
|
||||
event_type: String,
|
||||
#[allow(dead_code)]
|
||||
event_id: String,
|
||||
}
|
||||
|
||||
@ -217,6 +216,10 @@ const LARK_TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);
|
||||
const LARK_DEFAULT_TOKEN_TTL: Duration = Duration::from_secs(7200);
|
||||
/// Feishu/Lark API business code for expired/invalid tenant access token.
|
||||
const LARK_INVALID_ACCESS_TOKEN_CODE: i64 = 99_991_663;
|
||||
/// Retention window for seen event/message dedupe keys.
|
||||
const LARK_EVENT_DEDUP_TTL: Duration = Duration::from_secs(30 * 60);
|
||||
/// Periodic cleanup interval for the dedupe cache.
|
||||
const LARK_EVENT_DEDUP_CLEANUP_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT: &str =
|
||||
"[Image message received but could not be downloaded]";
|
||||
|
||||
@ -367,8 +370,10 @@ pub struct LarkChannel {
|
||||
receive_mode: crate::config::schema::LarkReceiveMode,
|
||||
/// Cached tenant access token
|
||||
tenant_token: Arc<RwLock<Option<CachedTenantToken>>>,
|
||||
/// Dedup set: WS message_ids seen in last ~30 min to prevent double-dispatch
|
||||
ws_seen_ids: Arc<RwLock<HashMap<String, Instant>>>,
|
||||
/// Dedup set for recently seen event/message keys across WS + webhook paths.
|
||||
recent_event_keys: Arc<RwLock<HashMap<String, Instant>>>,
|
||||
/// Last time we ran TTL cleanup over the dedupe cache.
|
||||
recent_event_cleanup_at: Arc<RwLock<Instant>>,
|
||||
}
|
||||
|
||||
impl LarkChannel {
|
||||
@ -412,7 +417,8 @@ impl LarkChannel {
|
||||
platform,
|
||||
receive_mode: crate::config::schema::LarkReceiveMode::default(),
|
||||
tenant_token: Arc::new(RwLock::new(None)),
|
||||
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
|
||||
recent_event_keys: Arc::new(RwLock::new(HashMap::new())),
|
||||
recent_event_cleanup_at: Arc::new(RwLock::new(Instant::now())),
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,6 +526,42 @@ impl LarkChannel {
|
||||
}
|
||||
}
|
||||
|
||||
fn dedupe_event_key(event_id: Option<&str>, message_id: Option<&str>) -> Option<String> {
|
||||
let normalized_event = event_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
if let Some(event_id) = normalized_event {
|
||||
return Some(format!("event:{event_id}"));
|
||||
}
|
||||
|
||||
let normalized_message = message_id.map(str::trim).filter(|value| !value.is_empty());
|
||||
normalized_message.map(|message_id| format!("message:{message_id}"))
|
||||
}
|
||||
|
||||
async fn try_mark_event_key_seen(&self, dedupe_key: &str) -> bool {
|
||||
let now = Instant::now();
|
||||
if self.recent_event_keys.read().await.contains_key(dedupe_key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let should_cleanup = {
|
||||
let last_cleanup = self.recent_event_cleanup_at.read().await;
|
||||
now.duration_since(*last_cleanup) >= LARK_EVENT_DEDUP_CLEANUP_INTERVAL
|
||||
};
|
||||
|
||||
let mut seen = self.recent_event_keys.write().await;
|
||||
if seen.contains_key(dedupe_key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if should_cleanup {
|
||||
seen.retain(|_, t| now.duration_since(*t) < LARK_EVENT_DEDUP_TTL);
|
||||
let mut last_cleanup = self.recent_event_cleanup_at.write().await;
|
||||
*last_cleanup = now;
|
||||
}
|
||||
|
||||
seen.insert(dedupe_key.to_string(), now);
|
||||
true
|
||||
}
|
||||
|
||||
async fn fetch_image_marker(&self, image_key: &str) -> anyhow::Result<String> {
|
||||
if image_key.trim().is_empty() {
|
||||
anyhow::bail!("empty image_key");
|
||||
@ -880,17 +922,14 @@ impl LarkChannel {
|
||||
|
||||
let lark_msg = &recv.message;
|
||||
|
||||
// Dedup
|
||||
{
|
||||
let now = Instant::now();
|
||||
let mut seen = self.ws_seen_ids.write().await;
|
||||
// GC
|
||||
seen.retain(|_, t| now.duration_since(*t) < Duration::from_secs(30 * 60));
|
||||
if seen.contains_key(&lark_msg.message_id) {
|
||||
tracing::debug!("Lark WS: dup {}", lark_msg.message_id);
|
||||
if let Some(dedupe_key) = Self::dedupe_event_key(
|
||||
Some(event.header.event_id.as_str()),
|
||||
Some(lark_msg.message_id.as_str()),
|
||||
) {
|
||||
if !self.try_mark_event_key_seen(&dedupe_key).await {
|
||||
tracing::debug!("Lark WS: duplicate event dropped ({dedupe_key})");
|
||||
continue;
|
||||
}
|
||||
seen.insert(lark_msg.message_id.clone(), now);
|
||||
}
|
||||
|
||||
// Decode content by type (mirrors clawdbot-feishu parsing)
|
||||
@ -1290,6 +1329,22 @@ impl LarkChannel {
|
||||
Some(e) => e,
|
||||
None => return messages,
|
||||
};
|
||||
let event_id = payload
|
||||
.pointer("/header/event_id")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
let message_id = event
|
||||
.pointer("/message/message_id")
|
||||
.and_then(|id| id.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty());
|
||||
if let Some(dedupe_key) = Self::dedupe_event_key(event_id, message_id) {
|
||||
if !self.try_mark_event_key_seen(&dedupe_key).await {
|
||||
tracing::debug!("Lark webhook: duplicate event dropped ({dedupe_key})");
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
||||
let open_id = event
|
||||
.pointer("/sender/sender_id/open_id")
|
||||
@ -2318,6 +2373,100 @@ mod tests {
|
||||
assert_eq!(msgs[0].content, LARK_IMAGE_DOWNLOAD_FALLBACK_TEXT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lark_parse_event_payload_async_dedupes_repeated_event_id() {
|
||||
let ch = LarkChannel::new(
|
||||
"id".into(),
|
||||
"secret".into(),
|
||||
"token".into(),
|
||||
None,
|
||||
vec!["*".into()],
|
||||
true,
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"header": {
|
||||
"event_type": "im.message.receive_v1",
|
||||
"event_id": "evt_abc"
|
||||
},
|
||||
"event": {
|
||||
"sender": { "sender_id": { "open_id": "ou_user" } },
|
||||
"message": {
|
||||
"message_id": "om_first",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
"chat_id": "oc_chat"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let first = ch.parse_event_payload_async(&payload).await;
|
||||
let second = ch.parse_event_payload_async(&payload).await;
|
||||
assert_eq!(first.len(), 1);
|
||||
assert!(second.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lark_parse_event_payload_async_dedupes_by_message_id_without_event_id() {
|
||||
let ch = LarkChannel::new(
|
||||
"id".into(),
|
||||
"secret".into(),
|
||||
"token".into(),
|
||||
None,
|
||||
vec!["*".into()],
|
||||
true,
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"header": {
|
||||
"event_type": "im.message.receive_v1"
|
||||
},
|
||||
"event": {
|
||||
"sender": { "sender_id": { "open_id": "ou_user" } },
|
||||
"message": {
|
||||
"message_id": "om_fallback",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
"chat_id": "oc_chat"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let first = ch.parse_event_payload_async(&payload).await;
|
||||
let second = ch.parse_event_payload_async(&payload).await;
|
||||
assert_eq!(first.len(), 1);
|
||||
assert!(second.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_mark_event_key_seen_cleans_up_expired_keys_periodically() {
|
||||
let ch = LarkChannel::new(
|
||||
"id".into(),
|
||||
"secret".into(),
|
||||
"token".into(),
|
||||
None,
|
||||
vec!["*".into()],
|
||||
true,
|
||||
);
|
||||
|
||||
{
|
||||
let mut seen = ch.recent_event_keys.write().await;
|
||||
seen.insert(
|
||||
"event:stale".to_string(),
|
||||
Instant::now() - LARK_EVENT_DEDUP_TTL - Duration::from_secs(5),
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let mut cleanup_at = ch.recent_event_cleanup_at.write().await;
|
||||
*cleanup_at =
|
||||
Instant::now() - LARK_EVENT_DEDUP_CLEANUP_INTERVAL - Duration::from_secs(1);
|
||||
}
|
||||
|
||||
assert!(ch.try_mark_event_key_seen("event:fresh").await);
|
||||
let seen = ch.recent_event_keys.read().await;
|
||||
assert!(!seen.contains_key("event:stale"));
|
||||
assert!(seen.contains_key("event:fresh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lark_parse_empty_text_skipped() {
|
||||
let ch = LarkChannel::new(
|
||||
|
||||
1145
src/channels/mod.rs
1145
src/channels/mod.rs
File diff suppressed because it is too large
Load Diff
523
src/channels/napcat.rs
Normal file
523
src/channels/napcat.rs
Normal file
@ -0,0 +1,523 @@
|
||||
use super::traits::{Channel, ChannelMessage, SendMessage};
|
||||
use crate::config::schema::NapcatConfig;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::time::{sleep, Duration};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use uuid::Uuid;
|
||||
|
||||
const NAPCAT_SEND_PRIVATE: &str = "/send_private_msg";
|
||||
const NAPCAT_SEND_GROUP: &str = "/send_group_msg";
|
||||
const NAPCAT_STATUS: &str = "/get_status";
|
||||
const NAPCAT_DEDUP_CAPACITY: usize = 10_000;
|
||||
const NAPCAT_MAX_BACKOFF_SECS: u64 = 60;
|
||||
|
||||
fn current_unix_timestamp_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn normalize_token(raw: &str) -> Option<String> {
|
||||
let token = raw.trim();
|
||||
(!token.is_empty()).then(|| token.to_string())
|
||||
}
|
||||
|
||||
fn derive_api_base_from_websocket(websocket_url: &str) -> Option<String> {
|
||||
let mut url = Url::parse(websocket_url).ok()?;
|
||||
match url.scheme() {
|
||||
"ws" => {
|
||||
url.set_scheme("http").ok()?;
|
||||
}
|
||||
"wss" => {
|
||||
url.set_scheme("https").ok()?;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
url.set_path("");
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
Some(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn compose_onebot_content(content: &str, reply_message_id: Option<&str>) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if let Some(reply_id) = reply_message_id {
|
||||
let trimmed = reply_id.trim();
|
||||
if !trimmed.is_empty() {
|
||||
parts.push(format!("[CQ:reply,id={trimmed}]"));
|
||||
}
|
||||
}
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(marker) = trimmed
|
||||
.strip_prefix("[IMAGE:")
|
||||
.and_then(|v| v.strip_suffix(']'))
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
parts.push(format!("[CQ:image,file={marker}]"));
|
||||
continue;
|
||||
}
|
||||
parts.push(line.to_string());
|
||||
}
|
||||
|
||||
parts.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
fn parse_message_segments(message: &Value) -> String {
|
||||
if let Some(text) = message.as_str() {
|
||||
return text.trim().to_string();
|
||||
}
|
||||
|
||||
let Some(segments) = message.as_array() else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut parts = Vec::new();
|
||||
for segment in segments {
|
||||
let seg_type = segment
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let data = segment.get("data");
|
||||
match seg_type {
|
||||
"text" => {
|
||||
if let Some(text) = data
|
||||
.and_then(|d| d.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
parts.push(text.to_string());
|
||||
}
|
||||
}
|
||||
"image" => {
|
||||
if let Some(url) = data
|
||||
.and_then(|d| d.get("url"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
parts.push(format!("[IMAGE:{url}]"));
|
||||
} else if let Some(file) = data
|
||||
.and_then(|d| d.get("file"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
parts.push(format!("[IMAGE:{file}]"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
parts.join("\n").trim().to_string()
|
||||
}
|
||||
|
||||
fn extract_message_id(event: &Value) -> String {
|
||||
event
|
||||
.get("message_id")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|v| v.to_string())
|
||||
.or_else(|| {
|
||||
event
|
||||
.get("message_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| Uuid::new_v4().to_string())
|
||||
}
|
||||
|
||||
fn extract_timestamp(event: &Value) -> u64 {
|
||||
event
|
||||
.get("time")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|v| u64::try_from(v).ok())
|
||||
.unwrap_or_else(current_unix_timestamp_secs)
|
||||
}
|
||||
|
||||
pub struct NapcatChannel {
|
||||
websocket_url: String,
|
||||
api_base_url: String,
|
||||
access_token: Option<String>,
|
||||
allowed_users: Vec<String>,
|
||||
dedup: Arc<RwLock<HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl NapcatChannel {
|
||||
pub fn from_config(config: NapcatConfig) -> Result<Self> {
|
||||
let websocket_url = config.websocket_url.trim().to_string();
|
||||
if websocket_url.is_empty() {
|
||||
anyhow::bail!("napcat.websocket_url cannot be empty");
|
||||
}
|
||||
|
||||
let api_base_url = if config.api_base_url.trim().is_empty() {
|
||||
derive_api_base_from_websocket(&websocket_url).ok_or_else(|| {
|
||||
anyhow!("napcat.api_base_url is required when websocket_url is not ws:// or wss://")
|
||||
})?
|
||||
} else {
|
||||
config.api_base_url.trim().trim_end_matches('/').to_string()
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
websocket_url,
|
||||
api_base_url,
|
||||
access_token: normalize_token(config.access_token.as_deref().unwrap_or_default()),
|
||||
allowed_users: config.allowed_users,
|
||||
dedup: Arc::new(RwLock::new(HashSet::new())),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||
}
|
||||
|
||||
async fn is_duplicate(&self, message_id: &str) -> bool {
|
||||
if message_id.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut dedup = self.dedup.write().await;
|
||||
if dedup.contains(message_id) {
|
||||
return true;
|
||||
}
|
||||
if dedup.len() >= NAPCAT_DEDUP_CAPACITY {
|
||||
let remove_n = dedup.len() / 2;
|
||||
let to_remove: Vec<String> = dedup.iter().take(remove_n).cloned().collect();
|
||||
for key in to_remove {
|
||||
dedup.remove(&key);
|
||||
}
|
||||
}
|
||||
dedup.insert(message_id.to_string());
|
||||
false
|
||||
}
|
||||
|
||||
fn http_client(&self) -> reqwest::Client {
|
||||
crate::config::build_runtime_proxy_client("channel.napcat")
|
||||
}
|
||||
|
||||
async fn post_onebot(&self, endpoint: &str, body: &Value) -> Result<()> {
|
||||
let url = format!("{}{}", self.api_base_url, endpoint);
|
||||
let mut request = self.http_client().post(&url).json(body);
|
||||
if let Some(token) = &self.access_token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let err = response.text().await.unwrap_or_default();
|
||||
let sanitized = crate::providers::sanitize_api_error(&err);
|
||||
anyhow::bail!("Napcat HTTP request failed ({status}): {sanitized}");
|
||||
}
|
||||
|
||||
let payload: Value = response.json().await.unwrap_or_else(|_| json!({}));
|
||||
if payload
|
||||
.get("retcode")
|
||||
.and_then(Value::as_i64)
|
||||
.is_some_and(|retcode| retcode != 0)
|
||||
{
|
||||
let msg = payload
|
||||
.get("wording")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| payload.get("msg").and_then(Value::as_str))
|
||||
.unwrap_or("unknown error");
|
||||
anyhow::bail!("Napcat returned retcode != 0: {msg}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_ws_request(&self) -> Result<tokio_tungstenite::tungstenite::http::Request<()>> {
|
||||
let mut ws_url =
|
||||
Url::parse(&self.websocket_url).with_context(|| "invalid napcat.websocket_url")?;
|
||||
if let Some(token) = &self.access_token {
|
||||
let has_access_token = ws_url.query_pairs().any(|(k, _)| k == "access_token");
|
||||
if !has_access_token {
|
||||
ws_url.query_pairs_mut().append_pair("access_token", token);
|
||||
}
|
||||
}
|
||||
|
||||
let mut request = ws_url.as_str().into_client_request()?;
|
||||
if let Some(token) = &self.access_token {
|
||||
let value = format!("Bearer {token}");
|
||||
request.headers_mut().insert(
|
||||
tokio_tungstenite::tungstenite::http::header::AUTHORIZATION,
|
||||
value
|
||||
.parse()
|
||||
.context("invalid napcat access token header")?,
|
||||
);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
async fn parse_message_event(&self, event: &Value) -> Option<ChannelMessage> {
|
||||
if event.get("post_type").and_then(Value::as_str) != Some("message") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let message_id = extract_message_id(event);
|
||||
if self.is_duplicate(&message_id).await {
|
||||
return None;
|
||||
}
|
||||
|
||||
let message_type = event
|
||||
.get("message_type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
let sender_id = event
|
||||
.get("user_id")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|v| v.to_string())
|
||||
.or_else(|| {
|
||||
event
|
||||
.get("sender")
|
||||
.and_then(|s| s.get("user_id"))
|
||||
.and_then(Value::as_i64)
|
||||
.map(|v| v.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
|
||||
if !self.is_user_allowed(&sender_id) {
|
||||
tracing::warn!("Napcat: ignoring message from unauthorized user: {sender_id}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = {
|
||||
let parsed = parse_message_segments(event.get("message").unwrap_or(&Value::Null));
|
||||
if parsed.is_empty() {
|
||||
event
|
||||
.get("raw_message")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
} else {
|
||||
parsed
|
||||
}
|
||||
};
|
||||
|
||||
if content.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let reply_target = if message_type == "group" {
|
||||
let group_id = event
|
||||
.get("group_id")
|
||||
.and_then(Value::as_i64)
|
||||
.map(|v| v.to_string())
|
||||
.unwrap_or_default();
|
||||
format!("group:{group_id}")
|
||||
} else {
|
||||
format!("user:{sender_id}")
|
||||
};
|
||||
|
||||
Some(ChannelMessage {
|
||||
id: message_id.clone(),
|
||||
sender: sender_id,
|
||||
reply_target,
|
||||
content,
|
||||
channel: "napcat".to_string(),
|
||||
timestamp: extract_timestamp(event),
|
||||
// This is a message id for passive reply, not a thread id.
|
||||
thread_ts: Some(message_id),
|
||||
})
|
||||
}
|
||||
|
||||
async fn listen_once(&self, tx: &tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
let request = self.build_ws_request()?;
|
||||
let (mut socket, _) = connect_async(request).await?;
|
||||
tracing::info!("Napcat: connected to {}", self.websocket_url);
|
||||
|
||||
while let Some(frame) = socket.next().await {
|
||||
match frame {
|
||||
Ok(Message::Text(text)) => {
|
||||
let event: Value = match serde_json::from_str(&text) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
tracing::warn!("Napcat: failed to parse event payload: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(msg) = self.parse_message_event(&event).await {
|
||||
if tx.send(msg).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Message::Binary(_)) => {}
|
||||
Ok(Message::Ping(payload)) => {
|
||||
socket.send(Message::Pong(payload)).await?;
|
||||
}
|
||||
Ok(Message::Pong(_)) => {}
|
||||
Ok(Message::Close(frame)) => {
|
||||
return Err(anyhow!("Napcat websocket closed: {:?}", frame));
|
||||
}
|
||||
Ok(Message::Frame(_)) => {}
|
||||
Err(err) => {
|
||||
return Err(anyhow!("Napcat websocket error: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("Napcat websocket stream ended"))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for NapcatChannel {
|
||||
fn name(&self) -> &str {
|
||||
"napcat"
|
||||
}
|
||||
|
||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||
let payload = compose_onebot_content(&message.content, message.thread_ts.as_deref());
|
||||
if payload.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(group_id) = message.recipient.strip_prefix("group:") {
|
||||
let body = json!({
|
||||
"group_id": group_id,
|
||||
"message": payload,
|
||||
});
|
||||
self.post_onebot(NAPCAT_SEND_GROUP, &body).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let user_id = message
|
||||
.recipient
|
||||
.strip_prefix("user:")
|
||||
.unwrap_or(&message.recipient)
|
||||
.trim();
|
||||
if user_id.is_empty() {
|
||||
anyhow::bail!("Napcat recipient is empty");
|
||||
}
|
||||
|
||||
let body = json!({
|
||||
"user_id": user_id,
|
||||
"message": payload,
|
||||
});
|
||||
self.post_onebot(NAPCAT_SEND_PRIVATE, &body).await
|
||||
}
|
||||
|
||||
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> Result<()> {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
loop {
|
||||
match self.listen_once(&tx).await {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
"Napcat listener error: {err}. Reconnecting in {:?}...",
|
||||
backoff
|
||||
);
|
||||
sleep(backoff).await;
|
||||
backoff =
|
||||
std::cmp::min(backoff * 2, Duration::from_secs(NAPCAT_MAX_BACKOFF_SECS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> bool {
|
||||
let url = format!("{}{}", self.api_base_url, NAPCAT_STATUS);
|
||||
let mut request = self.http_client().get(url);
|
||||
if let Some(token) = &self.access_token {
|
||||
request = request.bearer_auth(token);
|
||||
}
|
||||
request
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await
|
||||
.map(|resp| resp.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn derive_api_base_converts_ws_to_http() {
|
||||
let base = derive_api_base_from_websocket("ws://127.0.0.1:3001/ws").unwrap();
|
||||
assert_eq!(base, "http://127.0.0.1:3001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_onebot_content_includes_reply_and_image_markers() {
|
||||
let content = "hello\n[IMAGE:https://example.com/cat.png]";
|
||||
let parsed = compose_onebot_content(content, Some("123"));
|
||||
assert!(parsed.contains("[CQ:reply,id=123]"));
|
||||
assert!(parsed.contains("[CQ:image,file=https://example.com/cat.png]"));
|
||||
assert!(parsed.contains("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_private_event_maps_to_channel_message() {
|
||||
let cfg = NapcatConfig {
|
||||
websocket_url: "ws://127.0.0.1:3001".into(),
|
||||
api_base_url: "".into(),
|
||||
access_token: None,
|
||||
allowed_users: vec!["10001".into()],
|
||||
};
|
||||
let channel = NapcatChannel::from_config(cfg).unwrap();
|
||||
let event = json!({
|
||||
"post_type": "message",
|
||||
"message_type": "private",
|
||||
"message_id": 99,
|
||||
"user_id": 10001,
|
||||
"time": 1700000000,
|
||||
"message": [{"type":"text","data":{"text":"hi"}}]
|
||||
});
|
||||
|
||||
let msg = channel.parse_message_event(&event).await.unwrap();
|
||||
assert_eq!(msg.channel, "napcat");
|
||||
assert_eq!(msg.sender, "10001");
|
||||
assert_eq!(msg.reply_target, "user:10001");
|
||||
assert_eq!(msg.content, "hi");
|
||||
assert_eq!(msg.thread_ts.as_deref(), Some("99"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parse_group_event_with_image_segment() {
|
||||
let cfg = NapcatConfig {
|
||||
websocket_url: "ws://127.0.0.1:3001".into(),
|
||||
api_base_url: "".into(),
|
||||
access_token: None,
|
||||
allowed_users: vec!["*".into()],
|
||||
};
|
||||
let channel = NapcatChannel::from_config(cfg).unwrap();
|
||||
let event = json!({
|
||||
"post_type": "message",
|
||||
"message_type": "group",
|
||||
"message_id": "abc-1",
|
||||
"user_id": 20002,
|
||||
"group_id": 30003,
|
||||
"message": [
|
||||
{"type":"text","data":{"text":"photo"}},
|
||||
{"type":"image","data":{"url":"https://img.example.com/1.jpg"}}
|
||||
]
|
||||
});
|
||||
|
||||
let msg = channel.parse_message_event(&event).await.unwrap();
|
||||
assert_eq!(msg.reply_target, "group:30003");
|
||||
assert!(msg.content.contains("photo"));
|
||||
assert!(msg
|
||||
.content
|
||||
.contains("[IMAGE:https://img.example.com/1.jpg]"));
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,16 @@ use chrono::Utc;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use reqwest::header::HeaderMap;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedSlackDisplayName {
|
||||
display_name: String,
|
||||
expires_at: Instant,
|
||||
}
|
||||
|
||||
/// Slack channel — polls conversations.history via Web API
|
||||
pub struct SlackChannel {
|
||||
bot_token: String,
|
||||
@ -15,12 +22,14 @@ pub struct SlackChannel {
|
||||
allowed_users: Vec<String>,
|
||||
mention_only: bool,
|
||||
group_reply_allowed_sender_ids: Vec<String>,
|
||||
user_display_name_cache: Mutex<HashMap<String, CachedSlackDisplayName>>,
|
||||
}
|
||||
|
||||
const SLACK_HISTORY_MAX_RETRIES: u32 = 3;
|
||||
const SLACK_HISTORY_DEFAULT_RETRY_AFTER_SECS: u64 = 1;
|
||||
const SLACK_HISTORY_MAX_BACKOFF_SECS: u64 = 120;
|
||||
const SLACK_HISTORY_MAX_JITTER_MS: u64 = 500;
|
||||
const SLACK_USER_CACHE_TTL_SECS: u64 = 6 * 60 * 60;
|
||||
|
||||
impl SlackChannel {
|
||||
pub fn new(
|
||||
@ -36,6 +45,7 @@ impl SlackChannel {
|
||||
allowed_users,
|
||||
mention_only: false,
|
||||
group_reply_allowed_sender_ids: Vec::new(),
|
||||
user_display_name_cache: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,6 +140,137 @@ impl SlackChannel {
|
||||
normalized
|
||||
}
|
||||
|
||||
fn user_cache_ttl() -> Duration {
|
||||
Duration::from_secs(SLACK_USER_CACHE_TTL_SECS)
|
||||
}
|
||||
|
||||
fn sanitize_display_name(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_user_display_name(payload: &serde_json::Value) -> Option<String> {
|
||||
let user = payload.get("user")?;
|
||||
let profile = user.get("profile");
|
||||
|
||||
let candidates = [
|
||||
profile
|
||||
.and_then(|p| p.get("display_name"))
|
||||
.and_then(|v| v.as_str()),
|
||||
profile
|
||||
.and_then(|p| p.get("display_name_normalized"))
|
||||
.and_then(|v| v.as_str()),
|
||||
profile
|
||||
.and_then(|p| p.get("real_name_normalized"))
|
||||
.and_then(|v| v.as_str()),
|
||||
profile
|
||||
.and_then(|p| p.get("real_name"))
|
||||
.and_then(|v| v.as_str()),
|
||||
user.get("real_name").and_then(|v| v.as_str()),
|
||||
user.get("name").and_then(|v| v.as_str()),
|
||||
];
|
||||
|
||||
for candidate in candidates.into_iter().flatten() {
|
||||
if let Some(display_name) = Self::sanitize_display_name(candidate) {
|
||||
return Some(display_name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn cached_sender_display_name(&self, user_id: &str) -> Option<String> {
|
||||
let now = Instant::now();
|
||||
let Ok(mut cache) = self.user_display_name_cache.lock() else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if let Some(entry) = cache.get(user_id) {
|
||||
if now <= entry.expires_at {
|
||||
return Some(entry.display_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
cache.remove(user_id);
|
||||
None
|
||||
}
|
||||
|
||||
fn cache_sender_display_name(&self, user_id: &str, display_name: &str) {
|
||||
let Ok(mut cache) = self.user_display_name_cache.lock() else {
|
||||
return;
|
||||
};
|
||||
cache.insert(
|
||||
user_id.to_string(),
|
||||
CachedSlackDisplayName {
|
||||
display_name: display_name.to_string(),
|
||||
expires_at: Instant::now() + Self::user_cache_ttl(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async fn fetch_sender_display_name(&self, user_id: &str) -> Option<String> {
|
||||
let resp = match self
|
||||
.http_client()
|
||||
.get("https://slack.com/api/users.info")
|
||||
.bearer_auth(&self.bot_token)
|
||||
.query(&[("user", user_id)])
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
tracing::warn!("Slack users.info request failed for {user_id}: {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|e| format!("<failed to read response body: {e}>"));
|
||||
|
||||
if !status.is_success() {
|
||||
let sanitized = crate::providers::sanitize_api_error(&body);
|
||||
tracing::warn!("Slack users.info failed for {user_id} ({status}): {sanitized}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let payload: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
|
||||
if payload.get("ok") == Some(&serde_json::Value::Bool(false)) {
|
||||
let err = payload
|
||||
.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("unknown");
|
||||
tracing::warn!("Slack users.info returned error for {user_id}: {err}");
|
||||
return None;
|
||||
}
|
||||
|
||||
Self::extract_user_display_name(&payload)
|
||||
}
|
||||
|
||||
async fn resolve_sender_identity(&self, user_id: &str) -> String {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
if let Some(display_name) = self.cached_sender_display_name(user_id) {
|
||||
return display_name;
|
||||
}
|
||||
|
||||
if let Some(display_name) = self.fetch_sender_display_name(user_id).await {
|
||||
self.cache_sender_display_name(user_id, &display_name);
|
||||
return display_name;
|
||||
}
|
||||
|
||||
user_id.to_string()
|
||||
}
|
||||
|
||||
fn is_group_channel_id(channel_id: &str) -> bool {
|
||||
matches!(channel_id.chars().next(), Some('C' | 'G'))
|
||||
}
|
||||
@ -476,10 +617,11 @@ impl SlackChannel {
|
||||
};
|
||||
|
||||
last_ts_by_channel.insert(channel_id.clone(), ts.to_string());
|
||||
let sender = self.resolve_sender_identity(user).await;
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
id: format!("slack_{channel_id}_{ts}"),
|
||||
sender: user.to_string(),
|
||||
sender,
|
||||
reply_target: channel_id.clone(),
|
||||
content: normalized_text,
|
||||
channel: "slack".to_string(),
|
||||
@ -820,10 +962,11 @@ impl Channel for SlackChannel {
|
||||
};
|
||||
|
||||
last_ts_by_channel.insert(channel_id.clone(), ts.to_string());
|
||||
let sender = self.resolve_sender_identity(user).await;
|
||||
|
||||
let channel_msg = ChannelMessage {
|
||||
id: format!("slack_{channel_id}_{ts}"),
|
||||
sender: user.to_string(),
|
||||
sender,
|
||||
reply_target: channel_id.clone(),
|
||||
content: normalized_text,
|
||||
channel: "slack".to_string(),
|
||||
@ -952,6 +1095,72 @@ mod tests {
|
||||
assert!(ch.is_user_allowed("U12345"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_display_name_prefers_profile_display_name() {
|
||||
let payload = serde_json::json!({
|
||||
"ok": true,
|
||||
"user": {
|
||||
"name": "fallback_name",
|
||||
"profile": {
|
||||
"display_name": "Display Name",
|
||||
"real_name": "Real Name"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
SlackChannel::extract_user_display_name(&payload).as_deref(),
|
||||
Some("Display Name")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_display_name_falls_back_to_username() {
|
||||
let payload = serde_json::json!({
|
||||
"ok": true,
|
||||
"user": {
|
||||
"name": "fallback_name",
|
||||
"profile": {
|
||||
"display_name": " ",
|
||||
"real_name": ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
SlackChannel::extract_user_display_name(&payload).as_deref(),
|
||||
Some("fallback_name")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sender_display_name_returns_none_when_expired() {
|
||||
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec!["*".into()]);
|
||||
{
|
||||
let mut cache = ch.user_display_name_cache.lock().unwrap();
|
||||
cache.insert(
|
||||
"U123".to_string(),
|
||||
CachedSlackDisplayName {
|
||||
display_name: "Expired Name".to_string(),
|
||||
expires_at: Instant::now() - Duration::from_secs(1),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(ch.cached_sender_display_name("U123"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sender_display_name_returns_cached_value_when_valid() {
|
||||
let ch = SlackChannel::new("xoxb-fake".into(), None, None, vec!["*".into()]);
|
||||
ch.cache_sender_display_name("U123", "Cached Name");
|
||||
|
||||
assert_eq!(
|
||||
ch.cached_sender_display_name("U123").as_deref(),
|
||||
Some("Cached Name")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_incoming_content_requires_mention_when_enabled() {
|
||||
assert!(SlackChannel::normalize_incoming_content("hello", true, "U_BOT").is_none());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -185,6 +185,8 @@ 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 configuration (Groq Whisper)
|
||||
transcription: Option<crate::config::TranscriptionConfig>,
|
||||
}
|
||||
|
||||
impl WhatsAppWebChannel {
|
||||
@ -211,6 +213,43 @@ impl WhatsAppWebChannel {
|
||||
bot_handle: Arc::new(Mutex::new(None)),
|
||||
client: Arc::new(Mutex::new(None)),
|
||||
tx: Arc::new(Mutex::new(None)),
|
||||
transcription: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure voice transcription via Groq Whisper.
|
||||
///
|
||||
/// When `config.enabled` is false the builder is a no-op so callers can
|
||||
/// pass `config.transcription.clone()` unconditionally.
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
|
||||
if config.enabled {
|
||||
self.transcription = Some(config);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Map a WhatsApp audio MIME type to a filename accepted by the Groq Whisper API.
|
||||
///
|
||||
/// WhatsApp voice notes are typically `audio/ogg; codecs=opus`.
|
||||
/// MIME parameters (e.g. `; codecs=opus`) are stripped before matching so that
|
||||
/// `audio/webm; codecs=opus` maps to `voice.webm`, not `voice.opus`.
|
||||
#[cfg(feature = "whatsapp-web")]
|
||||
fn audio_mime_to_filename(mime: &str) -> &'static str {
|
||||
let base = mime
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
match base.as_str() {
|
||||
"audio/ogg" | "audio/oga" => "voice.ogg",
|
||||
"audio/webm" => "voice.webm",
|
||||
"audio/opus" => "voice.opus",
|
||||
"audio/mp4" | "audio/m4a" | "audio/aac" => "voice.m4a",
|
||||
"audio/mpeg" | "audio/mp3" => "voice.mp3",
|
||||
"audio/wav" | "audio/x-wav" => "voice.wav",
|
||||
_ => "voice.ogg",
|
||||
}
|
||||
}
|
||||
|
||||
@ -519,6 +558,7 @@ impl Channel for WhatsAppWebChannel {
|
||||
// Build the bot
|
||||
let tx_clone = tx.clone();
|
||||
let allowed_numbers = self.allowed_numbers.clone();
|
||||
let transcription = self.transcription.clone();
|
||||
|
||||
let mut builder = Bot::builder()
|
||||
.with_backend(backend)
|
||||
@ -527,6 +567,7 @@ impl Channel for WhatsAppWebChannel {
|
||||
.on_event(move |event, _client| {
|
||||
let tx_inner = tx_clone.clone();
|
||||
let allowed_numbers = allowed_numbers.clone();
|
||||
let transcription = transcription.clone();
|
||||
async move {
|
||||
match event {
|
||||
Event::Message(msg, info) => {
|
||||
@ -551,13 +592,82 @@ impl Channel for WhatsAppWebChannel {
|
||||
|
||||
if allowed_numbers.iter().any(|n| n == "*" || n == &normalized) {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
let content = if !trimmed.is_empty() {
|
||||
trimmed.to_string()
|
||||
} else if let Some(ref tc) = transcription {
|
||||
// Attempt to transcribe audio/voice messages
|
||||
if let Some(ref audio_msg) = msg.audio_message {
|
||||
let duration_secs =
|
||||
audio_msg.seconds.unwrap_or(0) as u64;
|
||||
if duration_secs > tc.max_duration_secs {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: voice message too long \
|
||||
({duration_secs}s > {}s), skipping",
|
||||
tc.max_duration_secs
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mime = audio_msg
|
||||
.mimetype
|
||||
.as_deref()
|
||||
.unwrap_or("audio/ogg");
|
||||
let file_name =
|
||||
Self::audio_mime_to_filename(mime);
|
||||
// download() decrypts the media in one step.
|
||||
// audio_msg is Box<AudioMessage>; .as_ref() yields
|
||||
// &AudioMessage which implements Downloadable.
|
||||
match _client.download(audio_msg.as_ref()).await {
|
||||
Ok(audio_bytes) => {
|
||||
match super::transcription::transcribe_audio(
|
||||
audio_bytes,
|
||||
file_name,
|
||||
tc,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(t) if !t.trim().is_empty() => {
|
||||
format!("[Voice] {}", t.trim())
|
||||
}
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"WhatsApp Web: voice transcription \
|
||||
returned empty text, skipping"
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: voice transcription \
|
||||
failed: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"WhatsApp Web: failed to download voice \
|
||||
audio: {e}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"WhatsApp Web: ignoring non-text/non-audio \
|
||||
message from {}",
|
||||
normalized
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"WhatsApp Web: ignoring empty or non-text message from {}",
|
||||
"WhatsApp Web: ignoring empty or non-text message \
|
||||
from {}",
|
||||
normalized
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = tx_inner
|
||||
.send(ChannelMessage {
|
||||
@ -566,7 +676,7 @@ impl Channel for WhatsAppWebChannel {
|
||||
sender: normalized.clone(),
|
||||
// Reply to the originating chat JID (DM or group).
|
||||
reply_target: chat,
|
||||
content: trimmed.to_string(),
|
||||
content,
|
||||
timestamp: chrono::Utc::now().timestamp() as u64,
|
||||
thread_ts: None,
|
||||
})
|
||||
@ -916,4 +1026,69 @@ mod tests {
|
||||
assert_eq!(text, "Check [UNKNOWN:/foo] out");
|
||||
assert!(attachments.is_empty());
|
||||
}
|
||||
|
||||
#[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_skips_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 audio_mime_to_filename_maps_whatsapp_voice_note() {
|
||||
// WhatsApp voice notes typically use this MIME type
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/ogg; codecs=opus"),
|
||||
"voice.ogg"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/ogg"),
|
||||
"voice.ogg"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/opus"),
|
||||
"voice.opus"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/mp4"),
|
||||
"voice.m4a"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/mpeg"),
|
||||
"voice.mp3"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/wav"),
|
||||
"voice.wav"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/webm"),
|
||||
"voice.webm"
|
||||
);
|
||||
// Regression: webm+opus codec parameter must not match the opus branch
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/webm; codecs=opus"),
|
||||
"voice.webm"
|
||||
);
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("audio/x-wav"),
|
||||
"voice.wav"
|
||||
);
|
||||
// Unknown types default to ogg (safe default for WhatsApp voice notes)
|
||||
assert_eq!(
|
||||
WhatsAppWebChannel::audio_mime_to_filename("application/octet-stream"),
|
||||
"voice.ogg"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,25 +4,27 @@ pub mod traits;
|
||||
#[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, AgentSessionBackend, AgentSessionConfig, AgentSessionStrategy, AgentsIpcConfig,
|
||||
AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig, BuiltinHooksConfig,
|
||||
ChannelsConfig, ClassificationRule, ComposioConfig, Config, CoordinationConfig, CostConfig,
|
||||
CronConfig, DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, EconomicConfig,
|
||||
EconomicTokenPricing, EmbeddingRouteConfig, EstopConfig, FeishuConfig, GatewayConfig,
|
||||
GroupReplyConfig, GroupReplyMode, HardwareConfig, HardwareTransport, HeartbeatConfig,
|
||||
HooksConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
|
||||
build_runtime_proxy_client_with_timeouts, default_model_fallback_for_provider,
|
||||
resolve_default_model_id, runtime_proxy_config, set_runtime_proxy_config, AgentConfig,
|
||||
AgentsIpcConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
|
||||
BuiltinHooksConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config,
|
||||
CoordinationConfig, CostConfig, CronConfig, DelegateAgentConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EconomicConfig, EconomicTokenPricing, EmbeddingRouteConfig, EstopConfig,
|
||||
FeishuConfig, GatewayConfig, GroupReplyConfig, GroupReplyMode, HardwareConfig,
|
||||
HardwareTransport, HeartbeatConfig, HooksConfig, HttpRequestConfig,
|
||||
HttpRequestCredentialProfile, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
|
||||
MemoryConfig, ModelRouteConfig, MultimodalConfig, NextcloudTalkConfig,
|
||||
NonCliNaturalLanguageApprovalMode, ObservabilityConfig, OtpChallengeDelivery, OtpConfig,
|
||||
OtpMethod, PeripheralBoardConfig, PeripheralsConfig, PerplexityFilterConfig, PluginEntryConfig,
|
||||
PluginsConfig, ProviderConfig, ProxyConfig, ProxyScope, QdrantConfig,
|
||||
QueryClassificationConfig, ReliabilityConfig, ResearchPhaseConfig, ResearchTrigger,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
SecretsConfig, SecurityConfig, SecurityRoleConfig, SkillsConfig, SkillsPromptInjectionMode,
|
||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
||||
SyscallAnomalyConfig, TelegramConfig, TranscriptionConfig, TunnelConfig, UrlAccessConfig,
|
||||
WasmCapabilityEscalationMode, WasmConfig, WasmModuleHashPolicy, WasmRuntimeConfig,
|
||||
WasmSecurityConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
OtpMethod, OutboundLeakGuardAction, OutboundLeakGuardConfig, PeripheralBoardConfig,
|
||||
PeripheralsConfig, PerplexityFilterConfig, PluginEntryConfig, PluginsConfig, ProviderConfig,
|
||||
ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig,
|
||||
ResearchPhaseConfig, ResearchTrigger, ResourceLimitsConfig, RuntimeConfig, SandboxBackend,
|
||||
SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SecurityRoleConfig,
|
||||
SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig,
|
||||
StorageProviderSection, StreamMode, SyscallAnomalyConfig, TelegramConfig, TranscriptionConfig,
|
||||
TunnelConfig, UrlAccessConfig, WasmCapabilityEscalationMode, WasmConfig, WasmModuleHashPolicy,
|
||||
WasmRuntimeConfig, WasmSecurityConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
DEFAULT_MODEL_FALLBACK,
|
||||
};
|
||||
|
||||
pub fn name_and_presence<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
@ -53,6 +55,7 @@ mod tests {
|
||||
mention_only: false,
|
||||
group_reply: None,
|
||||
base_url: None,
|
||||
ack_enabled: true,
|
||||
};
|
||||
|
||||
let discord = DiscordConfig {
|
||||
@ -106,4 +109,17 @@ mod tests {
|
||||
assert_eq!(feishu.app_id, "app-id");
|
||||
assert_eq!(nextcloud_talk.base_url, "https://cloud.example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reexported_http_request_credential_profile_is_constructible() {
|
||||
let profile = HttpRequestCredentialProfile {
|
||||
header_name: "Authorization".into(),
|
||||
env_var: "OPENROUTER_API_KEY".into(),
|
||||
value_prefix: "Bearer ".into(),
|
||||
};
|
||||
|
||||
assert_eq!(profile.header_name, "Authorization");
|
||||
assert_eq!(profile.env_var, "OPENROUTER_API_KEY");
|
||||
assert_eq!(profile.value_prefix, "Bearer ");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,8 +1,10 @@
|
||||
#[cfg(feature = "channel-lark")]
|
||||
use crate::channels::LarkChannel;
|
||||
#[cfg(feature = "channel-matrix")]
|
||||
use crate::channels::MatrixChannel;
|
||||
use crate::channels::{
|
||||
Channel, DiscordChannel, EmailChannel, MattermostChannel, QQChannel, SendMessage, SlackChannel,
|
||||
TelegramChannel, WhatsAppChannel,
|
||||
Channel, DiscordChannel, EmailChannel, MattermostChannel, NapcatChannel, QQChannel,
|
||||
SendMessage, SlackChannel, TelegramChannel, WhatsAppChannel,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::cron::{
|
||||
@ -334,6 +336,7 @@ pub(crate) async fn deliver_announcement(
|
||||
tg.bot_token.clone(),
|
||||
tg.allowed_users.clone(),
|
||||
tg.mention_only,
|
||||
tg.ack_enabled,
|
||||
)
|
||||
.with_workspace_dir(config.workspace_dir.clone());
|
||||
channel.send(&SendMessage::new(output, target)).await?;
|
||||
@ -398,6 +401,15 @@ pub(crate) async fn deliver_announcement(
|
||||
);
|
||||
channel.send(&SendMessage::new(output, target)).await?;
|
||||
}
|
||||
"napcat" => {
|
||||
let napcat_cfg = config
|
||||
.channels_config
|
||||
.napcat
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("napcat channel not configured"))?;
|
||||
let channel = NapcatChannel::from_config(napcat_cfg.clone())?;
|
||||
channel.send(&SendMessage::new(output, target)).await?;
|
||||
}
|
||||
"whatsapp_web" | "whatsapp" => {
|
||||
let wa = config
|
||||
.channels_config
|
||||
@ -464,6 +476,30 @@ pub(crate) async fn deliver_announcement(
|
||||
let channel = EmailChannel::new(email.clone());
|
||||
channel.send(&SendMessage::new(output, target)).await?;
|
||||
}
|
||||
"matrix" => {
|
||||
#[cfg(feature = "channel-matrix")]
|
||||
{
|
||||
// NOTE: uses the basic constructor without session hints (user_id/device_id).
|
||||
// Plain (non-E2EE) Matrix rooms work fine. Encrypted-room delivery is not
|
||||
// supported in cron mode; use start_channels for full E2EE listener sessions.
|
||||
let mx = config
|
||||
.channels_config
|
||||
.matrix
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("matrix channel not configured"))?;
|
||||
let channel = MatrixChannel::new(
|
||||
mx.homeserver.clone(),
|
||||
mx.access_token.clone(),
|
||||
mx.room_id.clone(),
|
||||
mx.allowed_users.clone(),
|
||||
);
|
||||
channel.send(&SendMessage::new(output, target)).await?;
|
||||
}
|
||||
#[cfg(not(feature = "channel-matrix"))]
|
||||
{
|
||||
anyhow::bail!("matrix delivery channel requires `channel-matrix` feature");
|
||||
}
|
||||
}
|
||||
other => anyhow::bail!("unsupported delivery channel: {other}"),
|
||||
}
|
||||
|
||||
|
||||
@ -245,7 +245,9 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::debug!("Heartbeat returned NO_REPLY sentinel; skipping delivery");
|
||||
tracing::debug!(
|
||||
"Heartbeat returned sentinel (NO_REPLY/HEARTBEAT_OK); skipping delivery"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@ -258,7 +260,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
|
||||
}
|
||||
|
||||
fn heartbeat_announcement_text(output: &str) -> Option<String> {
|
||||
if crate::cron::scheduler::is_no_reply_sentinel(output) {
|
||||
if crate::cron::scheduler::is_no_reply_sentinel(output) || is_heartbeat_ok_sentinel(output) {
|
||||
return None;
|
||||
}
|
||||
if output.trim().is_empty() {
|
||||
@ -267,6 +269,15 @@ fn heartbeat_announcement_text(output: &str) -> Option<String> {
|
||||
Some(output.to_string())
|
||||
}
|
||||
|
||||
fn is_heartbeat_ok_sentinel(output: &str) -> bool {
|
||||
const HEARTBEAT_OK: &str = "HEARTBEAT_OK";
|
||||
output
|
||||
.trim()
|
||||
.get(..HEARTBEAT_OK.len())
|
||||
.map(|prefix| prefix.eq_ignore_ascii_case(HEARTBEAT_OK))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn heartbeat_tasks_for_tick(
|
||||
file_tasks: Vec<String>,
|
||||
fallback_message: Option<&str>,
|
||||
@ -486,6 +497,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_enabled: true,
|
||||
group_reply: None,
|
||||
base_url: None,
|
||||
});
|
||||
@ -567,6 +579,16 @@ mod tests {
|
||||
assert!(heartbeat_announcement_text(" NO_reply ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_announcement_text_skips_heartbeat_ok_sentinel() {
|
||||
assert!(heartbeat_announcement_text(" heartbeat_ok ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_announcement_text_skips_heartbeat_ok_prefix_case_insensitive() {
|
||||
assert!(heartbeat_announcement_text(" heArTbEaT_oK - all clear ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heartbeat_announcement_text_uses_default_for_empty_output() {
|
||||
assert_eq!(
|
||||
@ -644,6 +666,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_enabled: true,
|
||||
group_reply: None,
|
||||
base_url: None,
|
||||
});
|
||||
|
||||
@ -684,7 +684,7 @@ impl TaskClassifier {
|
||||
occ.hourly_wage,
|
||||
occ.category,
|
||||
confidence,
|
||||
format!("Matched {:.0} keywords", best_score),
|
||||
format!("Matched {} keywords", best_score as i32),
|
||||
)
|
||||
} else {
|
||||
// Fallback
|
||||
|
||||
@ -930,8 +930,12 @@ mod tests {
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(35.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Struggling);
|
||||
|
||||
// Spend more to reach critical
|
||||
// At exactly 10% remaining, status is still struggling (critical is <10%).
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(25.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Struggling);
|
||||
|
||||
// Spend more to reach critical
|
||||
tracker.track_tokens(10_000_000, 0, "agent", Some(1.0));
|
||||
assert_eq!(tracker.get_survival_status(), SurvivalStatus::Critical);
|
||||
|
||||
// Bankrupt
|
||||
|
||||
@ -529,6 +529,48 @@ pub async fn handle_api_health(
|
||||
Json(serde_json::json!({"health": snapshot})).into_response()
|
||||
}
|
||||
|
||||
/// GET /api/pairing/devices — list paired devices
|
||||
pub async fn handle_api_pairing_devices(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
let devices = state.pairing.paired_devices();
|
||||
Json(serde_json::json!({ "devices": devices })).into_response()
|
||||
}
|
||||
|
||||
/// DELETE /api/pairing/devices/:id — revoke paired device
|
||||
pub async fn handle_api_pairing_device_revoke(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
if let Err(e) = require_auth(&state, &headers) {
|
||||
return e.into_response();
|
||||
}
|
||||
|
||||
if !state.pairing.revoke_device(&id) {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "Paired device not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = super::persist_pairing_tokens(state.config.clone(), &state.pairing).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": format!("Failed to persist pairing state: {e}")})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
Json(serde_json::json!({"status": "ok", "revoked": true, "id": id})).into_response()
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
fn normalize_dashboard_config_toml(root: &mut toml::Value) {
|
||||
@ -655,6 +697,10 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi
|
||||
mask_required_secret(&mut linq.api_token);
|
||||
mask_optional_secret(&mut linq.signing_secret);
|
||||
}
|
||||
if let Some(github) = masked.channels_config.github.as_mut() {
|
||||
mask_required_secret(&mut github.access_token);
|
||||
mask_optional_secret(&mut github.webhook_secret);
|
||||
}
|
||||
if let Some(wati) = masked.channels_config.wati.as_mut() {
|
||||
mask_required_secret(&mut wati.api_token);
|
||||
}
|
||||
@ -683,6 +729,9 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi
|
||||
if let Some(dingtalk) = masked.channels_config.dingtalk.as_mut() {
|
||||
mask_required_secret(&mut dingtalk.client_secret);
|
||||
}
|
||||
if let Some(napcat) = masked.channels_config.napcat.as_mut() {
|
||||
mask_optional_secret(&mut napcat.access_token);
|
||||
}
|
||||
if let Some(qq) = masked.channels_config.qq.as_mut() {
|
||||
mask_required_secret(&mut qq.app_secret);
|
||||
}
|
||||
@ -813,6 +862,13 @@ fn restore_masked_sensitive_fields(
|
||||
restore_required_secret(&mut incoming_ch.api_token, ¤t_ch.api_token);
|
||||
restore_optional_secret(&mut incoming_ch.signing_secret, ¤t_ch.signing_secret);
|
||||
}
|
||||
if let (Some(incoming_ch), Some(current_ch)) = (
|
||||
incoming.channels_config.github.as_mut(),
|
||||
current.channels_config.github.as_ref(),
|
||||
) {
|
||||
restore_required_secret(&mut incoming_ch.access_token, ¤t_ch.access_token);
|
||||
restore_optional_secret(&mut incoming_ch.webhook_secret, ¤t_ch.webhook_secret);
|
||||
}
|
||||
if let (Some(incoming_ch), Some(current_ch)) = (
|
||||
incoming.channels_config.wati.as_mut(),
|
||||
current.channels_config.wati.as_ref(),
|
||||
@ -874,6 +930,12 @@ fn restore_masked_sensitive_fields(
|
||||
) {
|
||||
restore_required_secret(&mut incoming_ch.client_secret, ¤t_ch.client_secret);
|
||||
}
|
||||
if let (Some(incoming_ch), Some(current_ch)) = (
|
||||
incoming.channels_config.napcat.as_mut(),
|
||||
current.channels_config.napcat.as_ref(),
|
||||
) {
|
||||
restore_optional_secret(&mut incoming_ch.access_token, ¤t_ch.access_token);
|
||||
}
|
||||
if let (Some(incoming_ch), Some(current_ch)) = (
|
||||
incoming.channels_config.qq.as_mut(),
|
||||
current.channels_config.qq.as_ref(),
|
||||
|
||||
@ -15,7 +15,7 @@ pub mod static_files;
|
||||
pub mod ws;
|
||||
|
||||
use crate::channels::{
|
||||
Channel, LinqChannel, NextcloudTalkChannel, QQChannel, SendMessage, WatiChannel,
|
||||
Channel, GitHubChannel, LinqChannel, NextcloudTalkChannel, QQChannel, SendMessage, WatiChannel,
|
||||
WhatsAppChannel,
|
||||
};
|
||||
use crate::config::Config;
|
||||
@ -70,6 +70,10 @@ fn linq_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String {
|
||||
format!("linq_{}_{}", msg.sender, msg.id)
|
||||
}
|
||||
|
||||
fn github_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String {
|
||||
format!("github_{}_{}", msg.sender, msg.id)
|
||||
}
|
||||
|
||||
fn wati_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String {
|
||||
format!("wati_{}_{}", msg.sender, msg.id)
|
||||
}
|
||||
@ -82,6 +86,17 @@ fn qq_memory_key(msg: &crate::channels::traits::ChannelMessage) -> String {
|
||||
format!("qq_{}_{}", msg.sender, msg.id)
|
||||
}
|
||||
|
||||
fn gateway_message_session_id(msg: &crate::channels::traits::ChannelMessage) -> String {
|
||||
if msg.channel == "qq" || msg.channel == "napcat" {
|
||||
return format!("{}_{}", msg.channel, msg.sender);
|
||||
}
|
||||
|
||||
match &msg.thread_ts {
|
||||
Some(thread_id) => format!("{}_{}_{}", msg.channel, thread_id, msg.sender),
|
||||
None => format!("{}_{}", msg.channel, msg.sender),
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_webhook_secret(value: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@ -622,6 +637,9 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
if linq_channel.is_some() {
|
||||
println!(" POST /linq — Linq message webhook (iMessage/RCS/SMS)");
|
||||
}
|
||||
if config.channels_config.github.is_some() {
|
||||
println!(" POST /github — GitHub issue/PR comment webhook");
|
||||
}
|
||||
if wati_channel.is_some() {
|
||||
println!(" GET /wati — WATI webhook verification");
|
||||
println!(" POST /wati — WATI message webhook");
|
||||
@ -734,6 +752,7 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/whatsapp", get(handle_whatsapp_verify))
|
||||
.route("/whatsapp", post(handle_whatsapp_message))
|
||||
.route("/linq", post(handle_linq_webhook))
|
||||
.route("/github", post(handle_github_webhook))
|
||||
.route("/wati", get(handle_wati_verify))
|
||||
.route("/wati", post(handle_wati_webhook))
|
||||
.route("/nextcloud-talk", post(handle_nextcloud_talk_webhook))
|
||||
@ -758,6 +777,11 @@ pub async fn run_gateway(host: &str, port: u16, config: Config) -> Result<()> {
|
||||
.route("/api/memory", get(api::handle_api_memory_list))
|
||||
.route("/api/memory", post(api::handle_api_memory_store))
|
||||
.route("/api/memory/{key}", delete(api::handle_api_memory_delete))
|
||||
.route("/api/pairing/devices", get(api::handle_api_pairing_devices))
|
||||
.route(
|
||||
"/api/pairing/devices/{id}",
|
||||
delete(api::handle_api_pairing_device_revoke),
|
||||
)
|
||||
.route("/api/cost", get(api::handle_api_cost))
|
||||
.route("/api/cli-tools", get(api::handle_api_cli_tools))
|
||||
.route("/api/health", get(api::handle_api_health))
|
||||
@ -981,26 +1005,36 @@ async fn run_gateway_chat_simple(state: &AppState, message: &str) -> anyhow::Res
|
||||
pub(super) async fn run_gateway_chat_with_tools(
|
||||
state: &AppState,
|
||||
message: &str,
|
||||
sender_id: &str,
|
||||
channel_name: &str,
|
||||
session_id: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
let config = state.config.lock().clone();
|
||||
Box::pin(crate::agent::process_message(
|
||||
config,
|
||||
message,
|
||||
sender_id,
|
||||
channel_name,
|
||||
))
|
||||
.await
|
||||
crate::agent::process_message_with_session(config, message, session_id).await
|
||||
}
|
||||
|
||||
fn sanitize_gateway_response(response: &str, tools: &[Box<dyn Tool>]) -> String {
|
||||
let sanitized = crate::channels::sanitize_channel_response(response, tools);
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
fn gateway_outbound_leak_guard_snapshot(
|
||||
state: &AppState,
|
||||
) -> crate::config::OutboundLeakGuardConfig {
|
||||
state.config.lock().security.outbound_leak_guard.clone()
|
||||
}
|
||||
|
||||
fn sanitize_gateway_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
match crate::channels::sanitize_channel_response(response, tools, leak_guard) {
|
||||
crate::channels::ChannelSanitizationResult::Sanitized(sanitized) => {
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
crate::channels::ChannelSanitizationResult::Blocked { .. } => {
|
||||
"I blocked a draft response because it appeared to contain credential material. Please ask for a redacted summary."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1010,6 +1044,8 @@ pub struct WebhookBody {
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub stream: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
@ -1235,9 +1271,11 @@ fn handle_webhook_streaming(
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state_for_call);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state_for_call.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
state_for_call.observer.record_event(
|
||||
@ -1525,6 +1563,11 @@ async fn handle_webhook(
|
||||
}
|
||||
|
||||
let message = webhook_body.message.trim();
|
||||
let webhook_session_id = webhook_body
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if message.is_empty() {
|
||||
let err = serde_json::json!({
|
||||
"error": "The `message` field is required and must be a non-empty string."
|
||||
@ -1536,7 +1579,12 @@ async fn handle_webhook(
|
||||
let key = webhook_memory_key();
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, message, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
&key,
|
||||
message,
|
||||
MemoryCategory::Conversation,
|
||||
webhook_session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@ -1616,8 +1664,12 @@ async fn handle_webhook(
|
||||
|
||||
match run_gateway_chat_simple(&state, message).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
state
|
||||
.observer
|
||||
@ -1803,27 +1855,30 @@ async fn handle_whatsapp_message(
|
||||
msg.sender,
|
||||
truncate_with_ellipsis(&msg.content, 50)
|
||||
);
|
||||
let session_id = gateway_message_session_id(msg);
|
||||
|
||||
// Auto-save to memory
|
||||
if state.auto_save {
|
||||
let key = whatsapp_memory_key(msg);
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &msg.content, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
&key,
|
||||
&msg.content,
|
||||
MemoryCategory::Conversation,
|
||||
Some(&session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&msg.content,
|
||||
&msg.sender,
|
||||
"whatsapp",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match run_gateway_chat_with_tools(&state, &msg.content, Some(&session_id)).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Send reply via WhatsApp
|
||||
if let Err(e) = wa
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
@ -1928,28 +1983,31 @@ async fn handle_linq_webhook(
|
||||
msg.sender,
|
||||
truncate_with_ellipsis(&msg.content, 50)
|
||||
);
|
||||
let session_id = gateway_message_session_id(msg);
|
||||
|
||||
// Auto-save to memory
|
||||
if state.auto_save {
|
||||
let key = linq_memory_key(msg);
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &msg.content, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
&key,
|
||||
&msg.content,
|
||||
MemoryCategory::Conversation,
|
||||
Some(&session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Call the LLM
|
||||
match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&msg.content,
|
||||
&msg.sender,
|
||||
"linq",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match run_gateway_chat_with_tools(&state, &msg.content, Some(&session_id)).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Send reply via Linq
|
||||
if let Err(e) = linq
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
@ -1974,6 +2032,180 @@ async fn handle_linq_webhook(
|
||||
(StatusCode::OK, Json(serde_json::json!({"status": "ok"})))
|
||||
}
|
||||
|
||||
/// POST /github — incoming GitHub webhook (issue/PR comments)
|
||||
#[allow(clippy::large_futures)]
|
||||
async fn handle_github_webhook(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let github_cfg = {
|
||||
let guard = state.config.lock();
|
||||
guard.channels_config.github.clone()
|
||||
};
|
||||
|
||||
let Some(github_cfg) = github_cfg else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({"error": "GitHub channel not configured"})),
|
||||
);
|
||||
};
|
||||
|
||||
let access_token = std::env::var("ZEROCLAW_GITHUB_TOKEN")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| github_cfg.access_token.trim().to_string());
|
||||
if access_token.is_empty() {
|
||||
tracing::error!(
|
||||
"GitHub webhook received but no access token is configured. \
|
||||
Set channels_config.github.access_token or ZEROCLAW_GITHUB_TOKEN."
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({"error": "GitHub access token is not configured"})),
|
||||
);
|
||||
}
|
||||
|
||||
let webhook_secret = std::env::var("ZEROCLAW_GITHUB_WEBHOOK_SECRET")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_string())
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| {
|
||||
github_cfg
|
||||
.webhook_secret
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
});
|
||||
|
||||
let event_name = headers
|
||||
.get("X-GitHub-Event")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty());
|
||||
let Some(event_name) = event_name else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Missing X-GitHub-Event header"})),
|
||||
);
|
||||
};
|
||||
|
||||
if let Some(secret) = webhook_secret.as_deref() {
|
||||
let signature = headers
|
||||
.get("X-Hub-Signature-256")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if !crate::channels::github::verify_github_signature(secret, &body, signature) {
|
||||
tracing::warn!(
|
||||
"GitHub webhook signature verification failed (signature: {})",
|
||||
if signature.is_empty() {
|
||||
"missing"
|
||||
} else {
|
||||
"invalid"
|
||||
}
|
||||
);
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(serde_json::json!({"error": "Invalid signature"})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(delivery_id) = headers
|
||||
.get("X-GitHub-Delivery")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.is_empty())
|
||||
{
|
||||
let key = format!("github:{delivery_id}");
|
||||
if !state.idempotency_store.record_if_new(&key) {
|
||||
tracing::info!("GitHub webhook duplicate ignored (delivery: {delivery_id})");
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(
|
||||
serde_json::json!({"status":"duplicate","idempotent":true,"delivery_id":delivery_id}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(payload) = serde_json::from_slice::<serde_json::Value>(&body) else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({"error": "Invalid JSON payload"})),
|
||||
);
|
||||
};
|
||||
|
||||
let github = GitHubChannel::new(
|
||||
access_token,
|
||||
github_cfg.api_base_url.clone(),
|
||||
github_cfg.allowed_repos.clone(),
|
||||
);
|
||||
let messages = github.parse_webhook_payload(event_name, &payload);
|
||||
if messages.is_empty() {
|
||||
return (
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "handled": false})),
|
||||
);
|
||||
}
|
||||
|
||||
for msg in &messages {
|
||||
tracing::info!(
|
||||
"GitHub webhook message from {}: {}",
|
||||
msg.sender,
|
||||
truncate_with_ellipsis(&msg.content, 80)
|
||||
);
|
||||
|
||||
if state.auto_save {
|
||||
let key = github_memory_key(msg);
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &msg.content, MemoryCategory::Conversation, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
match run_gateway_chat_with_tools(&state, &msg.content, None).await {
|
||||
Ok(response) => {
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
if let Err(e) = github
|
||||
.send(
|
||||
&SendMessage::new(safe_response, &msg.reply_target)
|
||||
.in_thread(msg.thread_ts.clone()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Failed to send GitHub reply: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("LLM error for GitHub webhook message: {e:#}");
|
||||
let _ = github
|
||||
.send(
|
||||
&SendMessage::new(
|
||||
"Sorry, I couldn't process your message right now.",
|
||||
&msg.reply_target,
|
||||
)
|
||||
.in_thread(msg.thread_ts.clone()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"status": "ok", "handled": true})),
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /wati — WATI webhook verification (echoes hub.challenge)
|
||||
async fn handle_wati_verify(
|
||||
State(state): State<AppState>,
|
||||
@ -2029,28 +2261,31 @@ async fn handle_wati_webhook(State(state): State<AppState>, body: Bytes) -> impl
|
||||
msg.sender,
|
||||
truncate_with_ellipsis(&msg.content, 50)
|
||||
);
|
||||
let session_id = gateway_message_session_id(msg);
|
||||
|
||||
// Auto-save to memory
|
||||
if state.auto_save {
|
||||
let key = wati_memory_key(msg);
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &msg.content, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
&key,
|
||||
&msg.content,
|
||||
MemoryCategory::Conversation,
|
||||
Some(&session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Call the LLM
|
||||
match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&msg.content,
|
||||
&msg.sender,
|
||||
"wati",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match run_gateway_chat_with_tools(&state, &msg.content, Some(&session_id)).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Send reply via WATI
|
||||
if let Err(e) = wati
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
@ -2144,26 +2379,29 @@ async fn handle_nextcloud_talk_webhook(
|
||||
msg.sender,
|
||||
truncate_with_ellipsis(&msg.content, 50)
|
||||
);
|
||||
let session_id = gateway_message_session_id(msg);
|
||||
|
||||
if state.auto_save {
|
||||
let key = nextcloud_talk_memory_key(msg);
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &msg.content, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
&key,
|
||||
&msg.content,
|
||||
MemoryCategory::Conversation,
|
||||
Some(&session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&msg.content,
|
||||
&msg.sender,
|
||||
"nextcloud_talk",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match run_gateway_chat_with_tools(&state, &msg.content, Some(&session_id)).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
if let Err(e) = nextcloud_talk
|
||||
.send(&SendMessage::new(safe_response, &msg.reply_target))
|
||||
.await
|
||||
@ -2242,26 +2480,29 @@ async fn handle_qq_webhook(
|
||||
msg.sender,
|
||||
truncate_with_ellipsis(&msg.content, 50)
|
||||
);
|
||||
let session_id = gateway_message_session_id(msg);
|
||||
|
||||
if state.auto_save {
|
||||
let key = qq_memory_key(msg);
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &msg.content, MemoryCategory::Conversation, None)
|
||||
.store(
|
||||
&key,
|
||||
&msg.content,
|
||||
MemoryCategory::Conversation,
|
||||
Some(&session_id),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&msg.content,
|
||||
&msg.sender,
|
||||
"qq",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match run_gateway_chat_with_tools(&state, &msg.content, Some(&session_id)).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = gateway_outbound_leak_guard_snapshot(&state);
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
if let Err(e) = qq
|
||||
.send(
|
||||
&SendMessage::new(safe_response, &msg.reply_target)
|
||||
@ -2823,7 +3064,8 @@ mod tests {
|
||||
</tool_call>
|
||||
After"#;
|
||||
|
||||
let result = sanitize_gateway_response(input, &[]);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_gateway_response(input, &[], &leak_guard);
|
||||
let normalized = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
@ -2841,12 +3083,27 @@ After"#;
|
||||
{"result":{"status":"scheduled"}}
|
||||
Reminder set successfully."#;
|
||||
|
||||
let result = sanitize_gateway_response(input, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_gateway_response(input, &tools, &leak_guard);
|
||||
assert_eq!(result, "Reminder set successfully.");
|
||||
assert!(!result.contains("\"name\":\"schedule\""));
|
||||
assert!(!result.contains("\"result\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_gateway_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
|
||||
let result =
|
||||
sanitize_gateway_response("Temporary key: AKIAABCDEFGHIJKLMNOP", &tools, &leak_guard);
|
||||
assert!(result.contains("blocked a draft response"));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockMemory;
|
||||
|
||||
@ -3026,6 +3283,7 @@ Reminder set successfully."#;
|
||||
let body = Ok(Json(WebhookBody {
|
||||
message: "hello".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
}));
|
||||
let first = handle_webhook(
|
||||
State(state.clone()),
|
||||
@ -3040,6 +3298,7 @@ Reminder set successfully."#;
|
||||
let body = Ok(Json(WebhookBody {
|
||||
message: "hello".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
}));
|
||||
let second = handle_webhook(State(state), test_connect_info(), headers, body)
|
||||
.await
|
||||
@ -3096,6 +3355,7 @@ Reminder set successfully."#;
|
||||
Ok(Json(WebhookBody {
|
||||
message: "hello".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
@ -3147,6 +3407,7 @@ Reminder set successfully."#;
|
||||
Ok(Json(WebhookBody {
|
||||
message: " ".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
@ -3199,6 +3460,7 @@ Reminder set successfully."#;
|
||||
Ok(Json(WebhookBody {
|
||||
message: "stream me".into(),
|
||||
stream: Some(true),
|
||||
session_id: None,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
@ -3371,6 +3633,7 @@ Reminder set successfully."#;
|
||||
let body1 = Ok(Json(WebhookBody {
|
||||
message: "hello one".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
}));
|
||||
let first = handle_webhook(
|
||||
State(state.clone()),
|
||||
@ -3385,6 +3648,7 @@ Reminder set successfully."#;
|
||||
let body2 = Ok(Json(WebhookBody {
|
||||
message: "hello two".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
}));
|
||||
let second = handle_webhook(State(state), test_connect_info(), headers, body2)
|
||||
.await
|
||||
@ -3456,6 +3720,7 @@ Reminder set successfully."#;
|
||||
Ok(Json(WebhookBody {
|
||||
message: "hello".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
@ -3516,6 +3781,7 @@ Reminder set successfully."#;
|
||||
Ok(Json(WebhookBody {
|
||||
message: "hello".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
@ -3572,6 +3838,7 @@ Reminder set successfully."#;
|
||||
Ok(Json(WebhookBody {
|
||||
message: "hello".into(),
|
||||
stream: None,
|
||||
session_id: None,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
@ -3591,6 +3858,201 @@ Reminder set successfully."#;
|
||||
hex::encode(mac.finalize().into_bytes())
|
||||
}
|
||||
|
||||
fn compute_github_signature_header(secret: &str, body: &str) -> String {
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body.as_bytes());
|
||||
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_webhook_returns_not_found_when_not_configured() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default());
|
||||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(Config::default())),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
mem: memory,
|
||||
auto_save: false,
|
||||
webhook_secret_hash: None,
|
||||
pairing: Arc::new(PairingGuard::new(false, &[])),
|
||||
trust_forwarded_headers: false,
|
||||
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
|
||||
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
|
||||
whatsapp: None,
|
||||
whatsapp_app_secret: None,
|
||||
linq: None,
|
||||
linq_signing_secret: None,
|
||||
nextcloud_talk: None,
|
||||
nextcloud_talk_webhook_secret: None,
|
||||
wati: None,
|
||||
qq: None,
|
||||
qq_webhook_enabled: false,
|
||||
observer: Arc::new(crate::observability::NoopObserver),
|
||||
tools_registry: Arc::new(Vec::new()),
|
||||
tools_registry_exec: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
max_tool_iterations: 10,
|
||||
cost_tracker: None,
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
};
|
||||
|
||||
let response = handle_github_webhook(
|
||||
State(state),
|
||||
HeaderMap::new(),
|
||||
Bytes::from_static(br#"{"action":"created"}"#),
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_webhook_rejects_invalid_signature() {
|
||||
let provider_impl = Arc::new(MockProvider::default());
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
let mut config = Config::default();
|
||||
config.channels_config.github = Some(crate::config::schema::GitHubConfig {
|
||||
access_token: "ghp_test_token".into(),
|
||||
webhook_secret: Some("github-secret".into()),
|
||||
api_base_url: None,
|
||||
allowed_repos: vec!["zeroclaw-labs/zeroclaw".into()],
|
||||
});
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
mem: memory,
|
||||
auto_save: false,
|
||||
webhook_secret_hash: None,
|
||||
pairing: Arc::new(PairingGuard::new(false, &[])),
|
||||
trust_forwarded_headers: false,
|
||||
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
|
||||
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
|
||||
whatsapp: None,
|
||||
whatsapp_app_secret: None,
|
||||
linq: None,
|
||||
linq_signing_secret: None,
|
||||
nextcloud_talk: None,
|
||||
nextcloud_talk_webhook_secret: None,
|
||||
wati: None,
|
||||
qq: None,
|
||||
qq_webhook_enabled: false,
|
||||
observer: Arc::new(crate::observability::NoopObserver),
|
||||
tools_registry: Arc::new(Vec::new()),
|
||||
tools_registry_exec: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
max_tool_iterations: 10,
|
||||
cost_tracker: None,
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
};
|
||||
|
||||
let body = r#"{
|
||||
"action":"created",
|
||||
"repository":{"full_name":"zeroclaw-labs/zeroclaw"},
|
||||
"issue":{"number":2079,"title":"x"},
|
||||
"comment":{"id":1,"body":"hello","user":{"login":"alice","type":"User"}}
|
||||
}"#;
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-GitHub-Event", HeaderValue::from_static("issue_comment"));
|
||||
headers.insert(
|
||||
"X-Hub-Signature-256",
|
||||
HeaderValue::from_static("sha256=deadbeef"),
|
||||
);
|
||||
|
||||
let response = handle_github_webhook(State(state), headers, Bytes::from(body))
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn github_webhook_duplicate_delivery_returns_duplicate_status() {
|
||||
let provider_impl = Arc::new(MockProvider::default());
|
||||
let provider: Arc<dyn Provider> = provider_impl.clone();
|
||||
let memory: Arc<dyn Memory> = Arc::new(MockMemory);
|
||||
let secret = "github-secret";
|
||||
let mut config = Config::default();
|
||||
config.channels_config.github = Some(crate::config::schema::GitHubConfig {
|
||||
access_token: "ghp_test_token".into(),
|
||||
webhook_secret: Some(secret.into()),
|
||||
api_base_url: None,
|
||||
allowed_repos: vec!["zeroclaw-labs/zeroclaw".into()],
|
||||
});
|
||||
|
||||
let state = AppState {
|
||||
config: Arc::new(Mutex::new(config)),
|
||||
provider,
|
||||
model: "test-model".into(),
|
||||
temperature: 0.0,
|
||||
mem: memory,
|
||||
auto_save: false,
|
||||
webhook_secret_hash: None,
|
||||
pairing: Arc::new(PairingGuard::new(false, &[])),
|
||||
trust_forwarded_headers: false,
|
||||
rate_limiter: Arc::new(GatewayRateLimiter::new(100, 100, 100)),
|
||||
idempotency_store: Arc::new(IdempotencyStore::new(Duration::from_secs(300), 1000)),
|
||||
whatsapp: None,
|
||||
whatsapp_app_secret: None,
|
||||
linq: None,
|
||||
linq_signing_secret: None,
|
||||
nextcloud_talk: None,
|
||||
nextcloud_talk_webhook_secret: None,
|
||||
wati: None,
|
||||
qq: None,
|
||||
qq_webhook_enabled: false,
|
||||
observer: Arc::new(crate::observability::NoopObserver),
|
||||
tools_registry: Arc::new(Vec::new()),
|
||||
tools_registry_exec: Arc::new(Vec::new()),
|
||||
multimodal: crate::config::MultimodalConfig::default(),
|
||||
max_tool_iterations: 10,
|
||||
cost_tracker: None,
|
||||
event_tx: tokio::sync::broadcast::channel(16).0,
|
||||
};
|
||||
|
||||
let body = r#"{
|
||||
"action":"created",
|
||||
"repository":{"full_name":"zeroclaw-labs/zeroclaw"},
|
||||
"issue":{"number":2079,"title":"x"},
|
||||
"comment":{"id":1,"body":"hello","user":{"login":"alice","type":"User"}}
|
||||
}"#;
|
||||
let signature = compute_github_signature_header(secret, body);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("X-GitHub-Event", HeaderValue::from_static("issue_comment"));
|
||||
headers.insert(
|
||||
"X-Hub-Signature-256",
|
||||
HeaderValue::from_str(&signature).unwrap(),
|
||||
);
|
||||
headers.insert("X-GitHub-Delivery", HeaderValue::from_static("delivery-1"));
|
||||
|
||||
let first = handle_github_webhook(
|
||||
State(state.clone()),
|
||||
headers.clone(),
|
||||
Bytes::from(body.to_string()),
|
||||
)
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
|
||||
let second = handle_github_webhook(State(state), headers, Bytes::from(body.to_string()))
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(second.status(), StatusCode::OK);
|
||||
let payload = second.into_body().collect().await.unwrap().to_bytes();
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(parsed["status"], "duplicate");
|
||||
assert_eq!(provider_impl.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nextcloud_talk_webhook_returns_not_found_when_not_configured() {
|
||||
let provider: Arc<dyn Provider> = Arc::new(MockProvider::default());
|
||||
|
||||
@ -275,11 +275,17 @@ async fn handle_non_streaming(
|
||||
.await
|
||||
{
|
||||
Ok(response_text) => {
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
let safe_response = sanitize_openai_compat_response(
|
||||
&response_text,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
record_success(&state, &provider_label, &model, duration);
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let completion_tokens = (response_text.len() / 4) as u32;
|
||||
let completion_tokens = (safe_response.len() / 4) as u32;
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let prompt_tokens = messages.iter().map(|m| m.content.len() / 4).sum::<usize>() as u32;
|
||||
|
||||
@ -292,7 +298,7 @@ async fn handle_non_streaming(
|
||||
index: 0,
|
||||
message: ChatCompletionsResponseMessage {
|
||||
role: "assistant",
|
||||
content: response_text,
|
||||
content: safe_response,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
}],
|
||||
@ -338,6 +344,71 @@ fn handle_streaming(
|
||||
) -> impl IntoResponse {
|
||||
let request_id = format!("chatcmpl-{}", Uuid::new_v4());
|
||||
let created = unix_timestamp();
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
|
||||
// Security-first behavior: when outbound leak guard is enabled, do not emit live
|
||||
// unvetted deltas. Buffer full provider output, sanitize once, then send SSE.
|
||||
if leak_guard_cfg.enabled {
|
||||
let model_clone = model.clone();
|
||||
let id = request_id.clone();
|
||||
let tools_registry = state.tools_registry_exec.clone();
|
||||
let leak_guard = leak_guard_cfg.clone();
|
||||
|
||||
let stream = futures_util::stream::once(async move {
|
||||
match state
|
||||
.provider
|
||||
.chat_with_history(&messages, &model_clone, temperature)
|
||||
.await
|
||||
{
|
||||
Ok(text) => {
|
||||
let safe_text = sanitize_openai_compat_response(
|
||||
&text,
|
||||
tools_registry.as_ref(),
|
||||
&leak_guard,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
record_success(&state, &provider_label, &model_clone, duration);
|
||||
|
||||
let chunk = ChatCompletionsChunk {
|
||||
id: id.clone(),
|
||||
object: "chat.completion.chunk",
|
||||
created,
|
||||
model: model_clone,
|
||||
choices: vec![ChunkChoice {
|
||||
index: 0,
|
||||
delta: ChunkDelta {
|
||||
role: Some("assistant"),
|
||||
content: Some(safe_text),
|
||||
},
|
||||
finish_reason: Some("stop"),
|
||||
}],
|
||||
};
|
||||
let json = serde_json::to_string(&chunk).unwrap_or_else(|_| "{}".to_string());
|
||||
let mut output = format!("data: {json}\n\n");
|
||||
output.push_str("data: [DONE]\n\n");
|
||||
Ok::<_, std::io::Error>(axum::body::Bytes::from(output))
|
||||
}
|
||||
Err(e) => {
|
||||
let duration = started_at.elapsed();
|
||||
let sanitized = crate::providers::sanitize_api_error(&e.to_string());
|
||||
record_failure(&state, &provider_label, &model_clone, duration, &sanitized);
|
||||
|
||||
let error_json = serde_json::json!({"error": sanitized});
|
||||
let output = format!("data: {error_json}\n\ndata: [DONE]\n\n");
|
||||
Ok(axum::body::Bytes::from(output))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "text/event-stream")
|
||||
.header(header::CACHE_CONTROL, "no-cache")
|
||||
.header(header::CONNECTION, "keep-alive")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !state.provider.supports_streaming() {
|
||||
// Provider doesn't support streaming — fall back to a single-chunk response
|
||||
@ -579,6 +650,27 @@ fn record_failure(
|
||||
});
|
||||
}
|
||||
|
||||
fn sanitize_openai_compat_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn crate::tools::Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
match crate::channels::sanitize_channel_response(response, tools, leak_guard) {
|
||||
crate::channels::ChannelSanitizationResult::Sanitized(sanitized) => {
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
crate::channels::ChannelSanitizationResult::Blocked { .. } => {
|
||||
"I blocked a draft response because it appeared to contain credential material. Please ask for a redacted summary."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// TESTS
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@ -586,6 +678,7 @@ fn record_failure(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::Tool;
|
||||
|
||||
#[test]
|
||||
fn chat_completions_request_deserializes_minimal() {
|
||||
@ -717,4 +810,49 @@ mod tests {
|
||||
fn body_size_limit_is_512kb() {
|
||||
assert_eq!(CHAT_COMPLETIONS_MAX_BODY_SIZE, 524_288);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_openai_compat_response_redacts_detected_credentials() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let output = sanitize_openai_compat_response(
|
||||
"Temporary key: AKIAABCDEFGHIJKLMNOP",
|
||||
&tools,
|
||||
&leak_guard,
|
||||
);
|
||||
assert!(!output.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
assert!(output.contains("[REDACTED_AWS_CREDENTIAL]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_openai_compat_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
let output = sanitize_openai_compat_response(
|
||||
"Temporary key: AKIAABCDEFGHIJKLMNOP",
|
||||
&tools,
|
||||
&leak_guard,
|
||||
);
|
||||
assert!(output.contains("blocked a draft response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_openai_compat_response_skips_scan_when_disabled() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: false,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
let output = sanitize_openai_compat_response(
|
||||
"Temporary key: AKIAABCDEFGHIJKLMNOP",
|
||||
&tools,
|
||||
&leak_guard,
|
||||
);
|
||||
assert!(output.contains("AKIAABCDEFGHIJKLMNOP"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -131,6 +131,11 @@ pub async fn handle_api_chat(
|
||||
};
|
||||
|
||||
let message = chat_body.message.trim();
|
||||
let session_id = chat_body
|
||||
.session_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if message.is_empty() {
|
||||
let err = serde_json::json!({ "error": "Message cannot be empty" });
|
||||
return (StatusCode::BAD_REQUEST, Json(err));
|
||||
@ -141,7 +146,7 @@ pub async fn handle_api_chat(
|
||||
let key = api_chat_memory_key();
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, message, MemoryCategory::Conversation, None)
|
||||
.store(&key, message, MemoryCategory::Conversation, session_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
@ -186,18 +191,14 @@ pub async fn handle_api_chat(
|
||||
});
|
||||
|
||||
// ── Run the full agent loop ──
|
||||
let sender_id = chat_body.session_id.as_deref().unwrap_or(rate_key.as_str());
|
||||
match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&enriched_message,
|
||||
sender_id,
|
||||
"api_chat",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match run_gateway_chat_with_tools(&state, &enriched_message, session_id).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
let safe_response = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
state
|
||||
@ -523,6 +524,11 @@ pub async fn handle_v1_chat_completions_with_tools(
|
||||
};
|
||||
|
||||
let is_stream = request.stream.unwrap_or(false);
|
||||
let session_id = request
|
||||
.user
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
let request_id = format!("chatcmpl-{}", Uuid::new_v4().to_string().replace('-', ""));
|
||||
let created = unix_timestamp();
|
||||
|
||||
@ -531,7 +537,7 @@ pub async fn handle_v1_chat_completions_with_tools(
|
||||
let key = api_chat_memory_key();
|
||||
let _ = state
|
||||
.mem
|
||||
.store(&key, &message, MemoryCategory::Conversation, None)
|
||||
.store(&key, &message, MemoryCategory::Conversation, session_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
@ -566,16 +572,14 @@ pub async fn handle_v1_chat_completions_with_tools(
|
||||
);
|
||||
|
||||
// ── Run the full agent loop ──
|
||||
let reply = match Box::pin(run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&enriched_message,
|
||||
rate_key.as_str(),
|
||||
"openai_compat",
|
||||
))
|
||||
.await
|
||||
{
|
||||
let reply = match run_gateway_chat_with_tools(&state, &enriched_message, session_id).await {
|
||||
Ok(response) => {
|
||||
let safe = sanitize_gateway_response(&response, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = state.config.lock().security.outbound_leak_guard.clone();
|
||||
let safe = sanitize_gateway_response(
|
||||
&response,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
let duration = started_at.elapsed();
|
||||
|
||||
state
|
||||
|
||||
@ -11,12 +11,12 @@
|
||||
|
||||
use super::AppState;
|
||||
use crate::agent::loop_::{build_shell_policy_instructions, build_tool_instructions_from_specs};
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::memory::MemoryCategory;
|
||||
use crate::providers::ChatMessage;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
State, WebSocketUpgrade,
|
||||
RawQuery, State, WebSocketUpgrade,
|
||||
},
|
||||
http::{header, HeaderMap},
|
||||
response::IntoResponse,
|
||||
@ -25,14 +25,195 @@ use uuid::Uuid;
|
||||
|
||||
const EMPTY_WS_RESPONSE_FALLBACK: &str =
|
||||
"Tool execution completed, but the model returned no final text response. Please ask me to summarize the result.";
|
||||
const WS_HISTORY_MEMORY_KEY_PREFIX: &str = "gateway_ws_history";
|
||||
const MAX_WS_PERSISTED_TURNS: usize = 128;
|
||||
const MAX_WS_SESSION_ID_LEN: usize = 128;
|
||||
|
||||
fn sanitize_ws_response(response: &str, tools: &[Box<dyn crate::tools::Tool>]) -> String {
|
||||
let sanitized = crate::channels::sanitize_channel_response(response, tools);
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct WsQueryParams {
|
||||
token: Option<String>,
|
||||
session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
struct WsHistoryTurn {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq, Eq)]
|
||||
struct WsPersistedHistory {
|
||||
version: u8,
|
||||
messages: Vec<WsHistoryTurn>,
|
||||
}
|
||||
|
||||
fn normalize_ws_session_id(candidate: Option<&str>) -> Option<String> {
|
||||
let raw = candidate?.trim();
|
||||
if raw.is_empty() || raw.len() > MAX_WS_SESSION_ID_LEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
if raw
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_ws_query_params(raw_query: Option<&str>) -> WsQueryParams {
|
||||
let Some(query) = raw_query else {
|
||||
return WsQueryParams::default();
|
||||
};
|
||||
|
||||
let mut params = WsQueryParams::default();
|
||||
for kv in query.split('&') {
|
||||
let mut parts = kv.splitn(2, '=');
|
||||
let key = parts.next().unwrap_or("").trim();
|
||||
let value = parts.next().unwrap_or("").trim();
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match key {
|
||||
"token" if params.token.is_none() => {
|
||||
params.token = Some(value.to_string());
|
||||
}
|
||||
"session_id" if params.session_id.is_none() => {
|
||||
params.session_id = normalize_ws_session_id(Some(value));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
params
|
||||
}
|
||||
|
||||
fn ws_history_memory_key(session_id: &str) -> String {
|
||||
format!("{WS_HISTORY_MEMORY_KEY_PREFIX}:{session_id}")
|
||||
}
|
||||
|
||||
fn ws_history_turns_from_chat(history: &[ChatMessage]) -> Vec<WsHistoryTurn> {
|
||||
let mut turns = history
|
||||
.iter()
|
||||
.filter_map(|msg| match msg.role.as_str() {
|
||||
"user" | "assistant" => {
|
||||
let content = msg.content.trim();
|
||||
if content.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(WsHistoryTurn {
|
||||
role: msg.role.clone(),
|
||||
content: content.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if turns.len() > MAX_WS_PERSISTED_TURNS {
|
||||
let keep_from = turns.len().saturating_sub(MAX_WS_PERSISTED_TURNS);
|
||||
turns.drain(0..keep_from);
|
||||
}
|
||||
turns
|
||||
}
|
||||
|
||||
fn restore_chat_history(system_prompt: &str, turns: &[WsHistoryTurn]) -> Vec<ChatMessage> {
|
||||
let mut history = vec![ChatMessage::system(system_prompt)];
|
||||
for turn in turns {
|
||||
match turn.role.as_str() {
|
||||
"user" => history.push(ChatMessage::user(&turn.content)),
|
||||
"assistant" => history.push(ChatMessage::assistant(&turn.content)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
history
|
||||
}
|
||||
|
||||
async fn load_ws_history(
|
||||
state: &AppState,
|
||||
session_id: &str,
|
||||
system_prompt: &str,
|
||||
) -> Vec<ChatMessage> {
|
||||
let key = ws_history_memory_key(session_id);
|
||||
let Some(entry) = state.mem.get(&key).await.ok().flatten() else {
|
||||
return vec![ChatMessage::system(system_prompt)];
|
||||
};
|
||||
|
||||
let parsed = serde_json::from_str::<WsPersistedHistory>(&entry.content)
|
||||
.map(|history| history.messages)
|
||||
.or_else(|_| serde_json::from_str::<Vec<WsHistoryTurn>>(&entry.content));
|
||||
|
||||
match parsed {
|
||||
Ok(turns) => restore_chat_history(system_prompt, &turns),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to parse persisted websocket history for session {}: {}",
|
||||
session_id,
|
||||
err
|
||||
);
|
||||
vec![ChatMessage::system(system_prompt)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_ws_history(state: &AppState, session_id: &str, history: &[ChatMessage]) {
|
||||
let payload = WsPersistedHistory {
|
||||
version: 1,
|
||||
messages: ws_history_turns_from_chat(history),
|
||||
};
|
||||
let serialized = match serde_json::to_string(&payload) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Failed to serialize websocket history for session {}: {}",
|
||||
session_id,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let key = ws_history_memory_key(session_id);
|
||||
if let Err(err) = state
|
||||
.mem
|
||||
.store(
|
||||
&key,
|
||||
&serialized,
|
||||
MemoryCategory::Conversation,
|
||||
Some(session_id),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to persist websocket history for session {}: {}",
|
||||
session_id,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_ws_response(
|
||||
response: &str,
|
||||
tools: &[Box<dyn crate::tools::Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
match crate::channels::sanitize_channel_response(response, tools, leak_guard) {
|
||||
crate::channels::ChannelSanitizationResult::Sanitized(sanitized) => {
|
||||
if sanitized.is_empty() && !response.trim().is_empty() {
|
||||
"I encountered malformed tool-call output and could not produce a safe reply. Please try again."
|
||||
.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
crate::channels::ChannelSanitizationResult::Blocked { .. } => {
|
||||
"I blocked a draft response because it appeared to contain credential material. Please ask for a redacted summary."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,8 +277,9 @@ fn finalize_ws_response(
|
||||
response: &str,
|
||||
history: &[ChatMessage],
|
||||
tools: &[Box<dyn crate::tools::Tool>],
|
||||
leak_guard: &crate::config::OutboundLeakGuardConfig,
|
||||
) -> String {
|
||||
let sanitized = sanitize_ws_response(response, tools);
|
||||
let sanitized = sanitize_ws_response(response, tools, leak_guard);
|
||||
if !sanitized.trim().is_empty() {
|
||||
return sanitized;
|
||||
}
|
||||
@ -155,28 +337,33 @@ fn build_ws_system_prompt(
|
||||
pub async fn handle_ws_chat(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
RawQuery(query): RawQuery,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> impl IntoResponse {
|
||||
let query_params = parse_ws_query_params(query.as_deref());
|
||||
// Auth via Authorization header or websocket protocol token.
|
||||
if state.pairing.require_pairing() {
|
||||
let token = extract_ws_bearer_token(&headers).unwrap_or_default();
|
||||
let token =
|
||||
extract_ws_bearer_token(&headers, query_params.token.as_deref()).unwrap_or_default();
|
||||
if !state.pairing.is_authenticated(&token) {
|
||||
return (
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"Unauthorized — provide Authorization: Bearer <token> or Sec-WebSocket-Protocol: bearer.<token>",
|
||||
"Unauthorized — provide Authorization: Bearer <token>, Sec-WebSocket-Protocol: bearer.<token>, or ?token=<token>",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state))
|
||||
let session_id = query_params
|
||||
.session_id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, session_id))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
// Maintain conversation history for this WebSocket session
|
||||
let mut history: Vec<ChatMessage> = Vec::new();
|
||||
let ws_sender_id = Uuid::new_v4().to_string();
|
||||
async fn handle_socket(mut socket: WebSocket, state: AppState, session_id: String) {
|
||||
let ws_session_id = format!("ws_{}", Uuid::new_v4());
|
||||
|
||||
// Build system prompt once for the session
|
||||
let system_prompt = {
|
||||
@ -189,13 +376,17 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
)
|
||||
};
|
||||
|
||||
// Add system message to history
|
||||
history.push(ChatMessage::system(&system_prompt));
|
||||
|
||||
let _approval_manager = {
|
||||
let config_guard = state.config.lock();
|
||||
ApprovalManager::from_config(&config_guard.autonomy)
|
||||
};
|
||||
// Restore persisted history (if any) and replay to the client before processing new input.
|
||||
let mut history = load_ws_history(&state, &session_id, &system_prompt).await;
|
||||
let persisted_turns = ws_history_turns_from_chat(&history);
|
||||
let history_payload = serde_json::json!({
|
||||
"type": "history",
|
||||
"session_id": session_id.as_str(),
|
||||
"messages": persisted_turns,
|
||||
});
|
||||
let _ = socket
|
||||
.send(Message::Text(history_payload.to_string().into()))
|
||||
.await;
|
||||
|
||||
while let Some(msg) = socket.recv().await {
|
||||
let msg = match msg {
|
||||
@ -244,6 +435,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
|
||||
// Add user message to history
|
||||
history.push(ChatMessage::user(&content));
|
||||
persist_ws_history(&state, &session_id, &history).await;
|
||||
|
||||
// Get provider info
|
||||
let provider_label = state
|
||||
@ -261,19 +453,18 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
}));
|
||||
|
||||
// Full agentic loop with tools (includes WASM skills, shell, memory, etc.)
|
||||
match Box::pin(super::run_gateway_chat_with_tools(
|
||||
&state,
|
||||
&content,
|
||||
ws_sender_id.as_str(),
|
||||
"ws",
|
||||
))
|
||||
.await
|
||||
{
|
||||
match super::run_gateway_chat_with_tools(&state, &content, Some(&ws_session_id)).await {
|
||||
Ok(response) => {
|
||||
let safe_response =
|
||||
finalize_ws_response(&response, &history, state.tools_registry_exec.as_ref());
|
||||
let leak_guard_cfg = { state.config.lock().security.outbound_leak_guard.clone() };
|
||||
let safe_response = finalize_ws_response(
|
||||
&response,
|
||||
&history,
|
||||
state.tools_registry_exec.as_ref(),
|
||||
&leak_guard_cfg,
|
||||
);
|
||||
// Add assistant response to history
|
||||
history.push(ChatMessage::assistant(&safe_response));
|
||||
persist_ws_history(&state, &session_id, &history).await;
|
||||
|
||||
// Send the full response as a done message
|
||||
let done = serde_json::json!({
|
||||
@ -308,7 +499,7 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_ws_bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
fn extract_ws_bearer_token(headers: &HeaderMap, query_token: Option<&str>) -> Option<String> {
|
||||
if let Some(auth_header) = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
@ -321,19 +512,27 @@ fn extract_ws_bearer_token(headers: &HeaderMap) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
let offered = headers
|
||||
if let Some(offered) = headers
|
||||
.get(header::SEC_WEBSOCKET_PROTOCOL)
|
||||
.and_then(|value| value.to_str().ok())?;
|
||||
|
||||
for protocol in offered.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Some(token) = protocol.strip_prefix("bearer.") {
|
||||
if !token.trim().is_empty() {
|
||||
return Some(token.trim().to_string());
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
for protocol in offered.split(',').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
if let Some(token) = protocol.strip_prefix("bearer.") {
|
||||
if !token.trim().is_empty() {
|
||||
return Some(token.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
query_token
|
||||
.map(str::trim)
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn extract_query_token(raw_query: Option<&str>) -> Option<String> {
|
||||
parse_ws_query_params(raw_query).token
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -356,7 +555,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_ws_bearer_token(&headers).as_deref(),
|
||||
extract_ws_bearer_token(&headers, None).as_deref(),
|
||||
Some("from-auth-header")
|
||||
);
|
||||
}
|
||||
@ -370,7 +569,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_ws_bearer_token(&headers).as_deref(),
|
||||
extract_ws_bearer_token(&headers, None).as_deref(),
|
||||
Some("protocol-token")
|
||||
);
|
||||
}
|
||||
@ -387,7 +586,103 @@ mod tests {
|
||||
HeaderValue::from_static("zeroclaw.v1, bearer."),
|
||||
);
|
||||
|
||||
assert!(extract_ws_bearer_token(&headers).is_none());
|
||||
assert!(extract_ws_bearer_token(&headers, None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_bearer_token_reads_query_token_fallback() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(
|
||||
extract_ws_bearer_token(&headers, Some("query-token")).as_deref(),
|
||||
Some("query-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_ws_bearer_token_prefers_protocol_over_query_token() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::SEC_WEBSOCKET_PROTOCOL,
|
||||
HeaderValue::from_static("zeroclaw.v1, bearer.protocol-token"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_ws_bearer_token(&headers, Some("query-token")).as_deref(),
|
||||
Some("protocol-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_query_token_reads_token_param() {
|
||||
assert_eq!(
|
||||
extract_query_token(Some("foo=1&token=query-token&bar=2")).as_deref(),
|
||||
Some("query-token")
|
||||
);
|
||||
assert!(extract_query_token(Some("foo=1")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ws_query_params_reads_token_and_session_id() {
|
||||
let parsed = parse_ws_query_params(Some("foo=1&session_id=sess_123&token=query-token"));
|
||||
assert_eq!(parsed.token.as_deref(), Some("query-token"));
|
||||
assert_eq!(parsed.session_id.as_deref(), Some("sess_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ws_query_params_rejects_invalid_session_id() {
|
||||
let parsed = parse_ws_query_params(Some("session_id=../../etc/passwd"));
|
||||
assert!(parsed.session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_history_turns_from_chat_skips_system_and_non_dialog_turns() {
|
||||
let history = vec![
|
||||
ChatMessage::system("sys"),
|
||||
ChatMessage::user(" hello "),
|
||||
ChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: "ignored".to_string(),
|
||||
},
|
||||
ChatMessage::assistant(" world "),
|
||||
];
|
||||
|
||||
let turns = ws_history_turns_from_chat(&history);
|
||||
assert_eq!(
|
||||
turns,
|
||||
vec![
|
||||
WsHistoryTurn {
|
||||
role: "user".to_string(),
|
||||
content: "hello".to_string()
|
||||
},
|
||||
WsHistoryTurn {
|
||||
role: "assistant".to_string(),
|
||||
content: "world".to_string()
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_chat_history_applies_system_prompt_once() {
|
||||
let turns = vec![
|
||||
WsHistoryTurn {
|
||||
role: "user".to_string(),
|
||||
content: "u1".to_string(),
|
||||
},
|
||||
WsHistoryTurn {
|
||||
role: "assistant".to_string(),
|
||||
content: "a1".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let restored = restore_chat_history("sys", &turns);
|
||||
assert_eq!(restored.len(), 3);
|
||||
assert_eq!(restored[0].role, "system");
|
||||
assert_eq!(restored[0].content, "sys");
|
||||
assert_eq!(restored[1].role, "user");
|
||||
assert_eq!(restored[1].content, "u1");
|
||||
assert_eq!(restored[2].role, "assistant");
|
||||
assert_eq!(restored[2].content, "a1");
|
||||
}
|
||||
|
||||
struct MockScheduleTool;
|
||||
@ -428,7 +723,8 @@ mod tests {
|
||||
</tool_call>
|
||||
After"#;
|
||||
|
||||
let result = sanitize_ws_response(input, &[]);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_ws_response(input, &[], &leak_guard);
|
||||
let normalized = result
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
@ -446,12 +742,27 @@ After"#;
|
||||
{"result":{"status":"scheduled"}}
|
||||
Reminder set successfully."#;
|
||||
|
||||
let result = sanitize_ws_response(input, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = sanitize_ws_response(input, &tools, &leak_guard);
|
||||
assert_eq!(result, "Reminder set successfully.");
|
||||
assert!(!result.contains("\"name\":\"schedule\""));
|
||||
assert!(!result.contains("\"result\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ws_response_blocks_detected_credentials_when_configured() {
|
||||
let tools: Vec<Box<dyn Tool>> = Vec::new();
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig {
|
||||
enabled: true,
|
||||
action: crate::config::OutboundLeakGuardAction::Block,
|
||||
sensitivity: 0.7,
|
||||
};
|
||||
|
||||
let result =
|
||||
sanitize_ws_response("Temporary key: AKIAABCDEFGHIJKLMNOP", &tools, &leak_guard);
|
||||
assert!(result.contains("blocked a draft response"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ws_system_prompt_includes_tool_protocol_for_prompt_mode() {
|
||||
let config = crate::config::Config::default();
|
||||
@ -486,7 +797,8 @@ Reminder set successfully."#;
|
||||
),
|
||||
];
|
||||
|
||||
let result = finalize_ws_response("", &history, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = finalize_ws_response("", &history, &tools, &leak_guard);
|
||||
assert!(result.contains("Latest tool output:"));
|
||||
assert!(result.contains("Disk usage: 72%"));
|
||||
assert!(!result.contains("<tool_result"));
|
||||
@ -501,7 +813,8 @@ Reminder set successfully."#;
|
||||
.to_string(),
|
||||
}];
|
||||
|
||||
let result = finalize_ws_response("", &history, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = finalize_ws_response("", &history, &tools, &leak_guard);
|
||||
assert!(result.contains("Latest tool output:"));
|
||||
assert!(result.contains("/dev/disk3s1"));
|
||||
}
|
||||
@ -511,7 +824,8 @@ Reminder set successfully."#;
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(MockScheduleTool)];
|
||||
let history = vec![ChatMessage::system("sys")];
|
||||
|
||||
let result = finalize_ws_response("", &history, &tools);
|
||||
let leak_guard = crate::config::OutboundLeakGuardConfig::default();
|
||||
let result = finalize_ws_response("", &history, &tools, &leak_guard);
|
||||
assert_eq!(result, EMPTY_WS_RESPONSE_FALLBACK);
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +159,18 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
||||
}
|
||||
},
|
||||
},
|
||||
IntegrationEntry {
|
||||
name: "Napcat",
|
||||
description: "QQ via Napcat (OneBot)",
|
||||
category: IntegrationCategory::Chat,
|
||||
status_fn: |c| {
|
||||
if c.channels_config.napcat.is_some() {
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
}
|
||||
},
|
||||
},
|
||||
// ── AI Models ───────────────────────────────────────────
|
||||
IntegrationEntry {
|
||||
name: "OpenRouter",
|
||||
@ -514,9 +526,15 @@ pub fn all_integrations() -> Vec<IntegrationEntry> {
|
||||
// ── Productivity ────────────────────────────────────────
|
||||
IntegrationEntry {
|
||||
name: "GitHub",
|
||||
description: "Code, issues, PRs",
|
||||
description: "Native issue/PR comment channel",
|
||||
category: IntegrationCategory::Productivity,
|
||||
status_fn: |_| IntegrationStatus::ComingSoon,
|
||||
status_fn: |c| {
|
||||
if c.channels_config.github.is_some() {
|
||||
IntegrationStatus::Active
|
||||
} else {
|
||||
IntegrationStatus::Available
|
||||
}
|
||||
},
|
||||
},
|
||||
IntegrationEntry {
|
||||
name: "Notion",
|
||||
@ -819,6 +837,7 @@ mod tests {
|
||||
draft_update_interval_ms: 1000,
|
||||
interrupt_on_new_message: false,
|
||||
mention_only: false,
|
||||
ack_enabled: true,
|
||||
group_reply: None,
|
||||
base_url: None,
|
||||
});
|
||||
|
||||
@ -113,13 +113,13 @@ Add a new channel configuration.
|
||||
Provide the channel type and a JSON object with the required \
|
||||
configuration keys for that channel type.
|
||||
|
||||
Supported types: telegram, discord, slack, whatsapp, matrix, imessage, email.
|
||||
Supported types: telegram, discord, slack, whatsapp, github, matrix, imessage, email.
|
||||
|
||||
Examples:
|
||||
zeroclaw channel add telegram '{\"bot_token\":\"...\",\"name\":\"my-bot\"}'
|
||||
zeroclaw channel add discord '{\"bot_token\":\"...\",\"name\":\"my-discord\"}'")]
|
||||
Add {
|
||||
/// Channel type (telegram, discord, slack, whatsapp, matrix, imessage, email)
|
||||
/// Channel type (telegram, discord, slack, whatsapp, github, matrix, imessage, email)
|
||||
channel_type: String,
|
||||
/// Optional configuration as JSON
|
||||
config: String,
|
||||
|
||||
272
src/main.rs
272
src/main.rs
@ -41,6 +41,12 @@ use std::io::Write;
|
||||
use tracing::{info, warn};
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
enum QuotaFormat {
|
||||
Text,
|
||||
Json,
|
||||
}
|
||||
|
||||
fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
|
||||
let t: f64 = s.parse().map_err(|e| format!("{e}"))?;
|
||||
if !(0.0..=2.0).contains(&t) {
|
||||
@ -385,13 +391,37 @@ Examples:
|
||||
/// List supported AI providers
|
||||
Providers,
|
||||
|
||||
/// Manage channels (telegram, discord, slack)
|
||||
/// Show provider quota and rate limit status
|
||||
#[command(
|
||||
name = "providers-quota",
|
||||
long_about = "\
|
||||
Show provider quota and rate limit status.
|
||||
|
||||
Displays quota remaining, rate limit resets, circuit breaker state, \
|
||||
and per-profile breakdown for all configured providers. Helps diagnose \
|
||||
quota exhaustion and rate limiting issues.
|
||||
|
||||
Examples:
|
||||
zeroclaw providers-quota # text output, all providers
|
||||
zeroclaw providers-quota --format json # JSON output
|
||||
zeroclaw providers-quota --provider gemini # filter by provider"
|
||||
)]
|
||||
ProvidersQuota {
|
||||
/// Filter by provider name (optional, shows all if omitted)
|
||||
#[arg(long)]
|
||||
provider: Option<String>,
|
||||
|
||||
/// Output format (text or json)
|
||||
#[arg(long, value_enum, default_value_t = QuotaFormat::Text)]
|
||||
format: QuotaFormat,
|
||||
},
|
||||
/// Manage channels (telegram, discord, slack, github)
|
||||
#[command(long_about = "\
|
||||
Manage communication channels.
|
||||
|
||||
Add, remove, list, and health-check channels that connect ZeroClaw \
|
||||
to messaging platforms. Supported channel types: telegram, discord, \
|
||||
slack, whatsapp, matrix, imessage, email.
|
||||
slack, whatsapp, github, matrix, imessage, email.
|
||||
|
||||
Examples:
|
||||
zeroclaw channel list
|
||||
@ -488,13 +518,13 @@ Examples:
|
||||
#[command(long_about = "\
|
||||
Manage ZeroClaw configuration.
|
||||
|
||||
Inspect and export configuration settings. Use 'schema' to dump \
|
||||
the full JSON Schema for the config file, which documents every \
|
||||
available key, type, and default value.
|
||||
Inspect, query, and modify configuration settings.
|
||||
|
||||
Examples:
|
||||
zeroclaw config schema # print JSON Schema to stdout
|
||||
zeroclaw config schema > schema.json")]
|
||||
zeroclaw config show # show effective config (secrets masked)
|
||||
zeroclaw config get gateway.port # query a specific value by dot-path
|
||||
zeroclaw config set gateway.port 8080 # update a value and save to config.toml
|
||||
zeroclaw config schema # print full JSON Schema to stdout")]
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
config_command: ConfigCommands,
|
||||
@ -519,6 +549,20 @@ Examples:
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum ConfigCommands {
|
||||
/// Show the current effective configuration (secrets masked)
|
||||
Show,
|
||||
/// Get a specific configuration value by dot-path (e.g. "gateway.port")
|
||||
Get {
|
||||
/// Dot-separated config path, e.g. "security.estop.enabled"
|
||||
key: String,
|
||||
},
|
||||
/// Set a configuration value and save to config.toml
|
||||
Set {
|
||||
/// Dot-separated config path, e.g. "gateway.port"
|
||||
key: String,
|
||||
/// New value (string, number, boolean, or JSON for objects/arrays)
|
||||
value: String,
|
||||
},
|
||||
/// Dump the full configuration JSON Schema to stdout
|
||||
Schema,
|
||||
}
|
||||
@ -1050,6 +1094,14 @@ async fn main() -> Result<()> {
|
||||
ModelCommands::Status => onboard::run_models_status(&config).await,
|
||||
},
|
||||
|
||||
Commands::ProvidersQuota { provider, format } => {
|
||||
let format_str = match format {
|
||||
QuotaFormat::Text => "text",
|
||||
QuotaFormat::Json => "json",
|
||||
};
|
||||
providers::quota_cli::run(&config, provider.as_deref(), format_str).await
|
||||
}
|
||||
|
||||
Commands::Providers => {
|
||||
let providers = providers::list_providers();
|
||||
let current = config
|
||||
@ -1142,6 +1194,94 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
Commands::Config { config_command } => match config_command {
|
||||
ConfigCommands::Show => {
|
||||
let mut json =
|
||||
serde_json::to_value(&config).context("Failed to serialize config")?;
|
||||
redact_config_secrets(&mut json);
|
||||
println!("{}", serde_json::to_string_pretty(&json)?);
|
||||
Ok(())
|
||||
}
|
||||
ConfigCommands::Get { key } => {
|
||||
let mut json =
|
||||
serde_json::to_value(&config).context("Failed to serialize config")?;
|
||||
redact_config_secrets(&mut json);
|
||||
|
||||
let mut current = &json;
|
||||
for segment in key.split('.') {
|
||||
current = current
|
||||
.get(segment)
|
||||
.with_context(|| format!("Config path not found: {key}"))?;
|
||||
}
|
||||
|
||||
match current {
|
||||
serde_json::Value::String(s) => println!("{s}"),
|
||||
serde_json::Value::Bool(b) => println!("{b}"),
|
||||
serde_json::Value::Number(n) => println!("{n}"),
|
||||
serde_json::Value::Null => println!("null"),
|
||||
_ => println!("{}", serde_json::to_string_pretty(current)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ConfigCommands::Set { key, value } => {
|
||||
let mut json =
|
||||
serde_json::to_value(&config).context("Failed to serialize config")?;
|
||||
|
||||
// Parse the new value: try bool, then integer, then float, then JSON, then string
|
||||
let new_value = if value == "true" {
|
||||
serde_json::Value::Bool(true)
|
||||
} else if value == "false" {
|
||||
serde_json::Value::Bool(false)
|
||||
} else if value == "null" {
|
||||
serde_json::Value::Null
|
||||
} else if let Ok(n) = value.parse::<i64>() {
|
||||
serde_json::json!(n)
|
||||
} else if let Ok(n) = value.parse::<f64>() {
|
||||
serde_json::json!(n)
|
||||
} else if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&value) {
|
||||
// JSON object/array (e.g. '["a","b"]' or '{"key":"val"}')
|
||||
parsed
|
||||
} else {
|
||||
serde_json::Value::String(value.clone())
|
||||
};
|
||||
|
||||
// Navigate to the parent and set the leaf
|
||||
let segments: Vec<&str> = key.split('.').collect();
|
||||
if segments.is_empty() {
|
||||
bail!("Config key cannot be empty");
|
||||
}
|
||||
let (parents, leaf) = segments.split_at(segments.len() - 1);
|
||||
|
||||
let mut target = &mut json;
|
||||
for segment in parents {
|
||||
target = target
|
||||
.get_mut(*segment)
|
||||
.with_context(|| format!("Config path not found: {key}"))?;
|
||||
}
|
||||
|
||||
let leaf_key = leaf[0];
|
||||
if target.get(leaf_key).is_none() {
|
||||
bail!("Config path not found: {key}");
|
||||
}
|
||||
target[leaf_key] = new_value.clone();
|
||||
|
||||
// Deserialize back to Config and save.
|
||||
// Preserve runtime-only fields lost during JSON round-trip (#[serde(skip)]).
|
||||
let config_path = config.config_path.clone();
|
||||
let workspace_dir = config.workspace_dir.clone();
|
||||
config = serde_json::from_value(json)
|
||||
.context("Invalid value for this config key — type mismatch")?;
|
||||
config.config_path = config_path;
|
||||
config.workspace_dir = workspace_dir;
|
||||
config.save().await?;
|
||||
|
||||
// Show the saved value
|
||||
let display = match &new_value {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
println!("Set {key} = {display}");
|
||||
Ok(())
|
||||
}
|
||||
ConfigCommands::Schema => {
|
||||
let schema = schemars::schema_for!(config::Config);
|
||||
println!(
|
||||
@ -1154,6 +1294,48 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys whose values are masked in `config show` / `config get` output.
|
||||
const REDACTED_CONFIG_KEYS: &[&str] = &[
|
||||
"api_key",
|
||||
"api_keys",
|
||||
"bot_token",
|
||||
"paired_tokens",
|
||||
"db_url",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"secret_key",
|
||||
"webhook_secret",
|
||||
];
|
||||
|
||||
fn redact_config_secrets(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
for (k, v) in map.iter_mut() {
|
||||
if REDACTED_CONFIG_KEYS.contains(&k.as_str()) {
|
||||
match v {
|
||||
serde_json::Value::String(s) if !s.is_empty() => {
|
||||
*v = serde_json::Value::String("***REDACTED***".to_string());
|
||||
}
|
||||
serde_json::Value::Array(arr) if !arr.is_empty() => {
|
||||
*v = serde_json::json!(["***REDACTED***"]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
redact_config_secrets(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(arr) => {
|
||||
for item in arr.iter_mut() {
|
||||
redact_config_secrets(item);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_estop_command(
|
||||
config: &Config,
|
||||
estop_command: Option<EstopSubcommands>,
|
||||
@ -2140,4 +2322,80 @@ mod tests {
|
||||
other => panic!("expected estop resume command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_help_mentions_show_get_set_examples() {
|
||||
let cmd = Cli::command();
|
||||
let config_cmd = cmd
|
||||
.get_subcommands()
|
||||
.find(|subcommand| subcommand.get_name() == "config")
|
||||
.expect("config subcommand must exist");
|
||||
|
||||
let mut output = Vec::new();
|
||||
config_cmd
|
||||
.clone()
|
||||
.write_long_help(&mut output)
|
||||
.expect("help generation should succeed");
|
||||
let help = String::from_utf8(output).expect("help output should be utf-8");
|
||||
assert!(help.contains("zeroclaw config show"));
|
||||
assert!(help.contains("zeroclaw config get gateway.port"));
|
||||
assert!(help.contains("zeroclaw config set gateway.port 8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_cli_parses_show_get_set_subcommands() {
|
||||
let show =
|
||||
Cli::try_parse_from(["zeroclaw", "config", "show"]).expect("config show should parse");
|
||||
match show.command {
|
||||
Commands::Config {
|
||||
config_command: ConfigCommands::Show,
|
||||
} => {}
|
||||
other => panic!("expected config show, got {other:?}"),
|
||||
}
|
||||
|
||||
let get = Cli::try_parse_from(["zeroclaw", "config", "get", "gateway.port"])
|
||||
.expect("config get should parse");
|
||||
match get.command {
|
||||
Commands::Config {
|
||||
config_command: ConfigCommands::Get { key },
|
||||
} => assert_eq!(key, "gateway.port"),
|
||||
other => panic!("expected config get, got {other:?}"),
|
||||
}
|
||||
|
||||
let set = Cli::try_parse_from(["zeroclaw", "config", "set", "gateway.port", "8080"])
|
||||
.expect("config set should parse");
|
||||
match set.command {
|
||||
Commands::Config {
|
||||
config_command: ConfigCommands::Set { key, value },
|
||||
} => {
|
||||
assert_eq!(key, "gateway.port");
|
||||
assert_eq!(value, "8080");
|
||||
}
|
||||
other => panic!("expected config set, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_config_secrets_masks_nested_sensitive_values() {
|
||||
let mut payload = serde_json::json!({
|
||||
"api_key": "sk-test",
|
||||
"nested": {
|
||||
"bot_token": "token",
|
||||
"paired_tokens": ["abc", "def"],
|
||||
"non_secret": "ok"
|
||||
}
|
||||
});
|
||||
redact_config_secrets(&mut payload);
|
||||
|
||||
assert_eq!(payload["api_key"], serde_json::json!("***REDACTED***"));
|
||||
assert_eq!(
|
||||
payload["nested"]["bot_token"],
|
||||
serde_json::json!("***REDACTED***")
|
||||
);
|
||||
assert_eq!(
|
||||
payload["nested"]["paired_tokens"],
|
||||
serde_json::json!(["***REDACTED***"])
|
||||
);
|
||||
assert_eq!(payload["nested"]["non_secret"], serde_json::json!("ok"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -262,13 +262,14 @@ pub fn create_memory_with_storage_and_routes(
|
||||
));
|
||||
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
let mem = SqliteMemory::with_embedder(
|
||||
let mem = SqliteMemory::with_options(
|
||||
workspace_dir,
|
||||
embedder,
|
||||
config.vector_weight as f32,
|
||||
config.keyword_weight as f32,
|
||||
config.embedding_cache_size,
|
||||
config.sqlite_open_timeout_secs,
|
||||
&config.sqlite_journal_mode,
|
||||
)?;
|
||||
Ok(mem)
|
||||
}
|
||||
|
||||
@ -58,6 +58,30 @@ impl SqliteMemory {
|
||||
keyword_weight: f32,
|
||||
cache_max: usize,
|
||||
open_timeout_secs: Option<u64>,
|
||||
) -> anyhow::Result<Self> {
|
||||
Self::with_options(
|
||||
workspace_dir,
|
||||
embedder,
|
||||
vector_weight,
|
||||
keyword_weight,
|
||||
cache_max,
|
||||
open_timeout_secs,
|
||||
"wal",
|
||||
)
|
||||
}
|
||||
|
||||
/// Build SQLite memory with full options including journal mode.
|
||||
///
|
||||
/// `journal_mode` accepts `"wal"` (default, best performance) or `"delete"`
|
||||
/// (required for network/shared filesystems that lack shared-memory support).
|
||||
pub fn with_options(
|
||||
workspace_dir: &Path,
|
||||
embedder: Arc<dyn EmbeddingProvider>,
|
||||
vector_weight: f32,
|
||||
keyword_weight: f32,
|
||||
cache_max: usize,
|
||||
open_timeout_secs: Option<u64>,
|
||||
journal_mode: &str,
|
||||
) -> anyhow::Result<Self> {
|
||||
let db_path = workspace_dir.join("memory").join("brain.db");
|
||||
|
||||
@ -68,18 +92,27 @@ impl SqliteMemory {
|
||||
let conn = Self::open_connection(&db_path, open_timeout_secs)?;
|
||||
|
||||
// ── Production-grade PRAGMA tuning ──────────────────────
|
||||
// WAL mode: concurrent reads during writes, crash-safe
|
||||
// normal sync: 2× write speed, still durable on WAL
|
||||
// mmap 8 MB: let the OS page-cache serve hot reads
|
||||
// WAL mode: concurrent reads during writes, crash-safe (default)
|
||||
// DELETE mode: for shared/network filesystems without mmap/shm support
|
||||
// normal sync: 2× write speed, still durable
|
||||
// mmap 8 MB: let the OS page-cache serve hot reads (WAL only)
|
||||
// cache 2 MB: keep ~500 hot pages in-process
|
||||
// temp_store memory: temp tables never hit disk
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode = WAL;
|
||||
let journal_pragma = match journal_mode.to_lowercase().as_str() {
|
||||
"delete" => "PRAGMA journal_mode = DELETE;",
|
||||
_ => "PRAGMA journal_mode = WAL;",
|
||||
};
|
||||
let mmap_pragma = match journal_mode.to_lowercase().as_str() {
|
||||
"delete" => "PRAGMA mmap_size = 0;",
|
||||
_ => "PRAGMA mmap_size = 8388608;",
|
||||
};
|
||||
conn.execute_batch(&format!(
|
||||
"{journal_pragma}
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA mmap_size = 8388608;
|
||||
{mmap_pragma}
|
||||
PRAGMA cache_size = -2000;
|
||||
PRAGMA temp_store = MEMORY;",
|
||||
)?;
|
||||
PRAGMA temp_store = MEMORY;"
|
||||
))?;
|
||||
|
||||
Self::init_schema(&conn)?;
|
||||
|
||||
|
||||
@ -5,9 +5,10 @@ use crate::config::schema::{
|
||||
};
|
||||
use crate::config::{
|
||||
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
|
||||
HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig, LarkConfig, MatrixConfig,
|
||||
MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig,
|
||||
TelegramConfig, WebFetchConfig, WebSearchConfig, WebhookConfig,
|
||||
HeartbeatConfig, HttpRequestConfig, HttpRequestCredentialProfile, IMessageConfig,
|
||||
IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig,
|
||||
SecretsConfig, SlackConfig, StorageConfig, TelegramConfig, WebFetchConfig, WebSearchConfig,
|
||||
WebhookConfig,
|
||||
};
|
||||
use crate::hardware::{self, HardwareConfig};
|
||||
use crate::identity::{
|
||||
@ -417,6 +418,7 @@ fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig {
|
||||
snapshot_on_hygiene: false,
|
||||
auto_hydrate: true,
|
||||
sqlite_open_timeout_secs: None,
|
||||
sqlite_journal_mode: "wal".to_string(),
|
||||
qdrant: crate::config::QdrantConfig::default(),
|
||||
}
|
||||
}
|
||||
@ -3083,7 +3085,64 @@ fn provider_supports_device_flow(provider_name: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
fn http_request_productivity_allowed_domains() -> Vec<String> {
|
||||
vec![
|
||||
"api.github.com".to_string(),
|
||||
"github.com".to_string(),
|
||||
"api.linear.app".to_string(),
|
||||
"linear.app".to_string(),
|
||||
"calendar.googleapis.com".to_string(),
|
||||
"tasks.googleapis.com".to_string(),
|
||||
"www.googleapis.com".to_string(),
|
||||
"oauth2.googleapis.com".to_string(),
|
||||
"api.notion.com".to_string(),
|
||||
"api.trello.com".to_string(),
|
||||
"api.atlassian.com".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
fn parse_allowed_domains_csv(raw: &str) -> Vec<String> {
|
||||
raw.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prompt_allowed_domains_for_tool(tool_name: &str) -> Result<Vec<String>> {
|
||||
if tool_name == "http_request" {
|
||||
let options = vec![
|
||||
"Productivity starter allowlist (GitHub, Linear, Google, Notion, Trello, Atlassian)",
|
||||
"Allow all public domains (*)",
|
||||
"Custom domain list (comma-separated)",
|
||||
];
|
||||
let choice = Select::new()
|
||||
.with_prompt(" HTTP domain policy")
|
||||
.items(&options)
|
||||
.default(0)
|
||||
.interact()?;
|
||||
|
||||
return match choice {
|
||||
0 => Ok(http_request_productivity_allowed_domains()),
|
||||
1 => Ok(vec!["*".to_string()]),
|
||||
_ => {
|
||||
let raw: String = Input::new()
|
||||
.with_prompt(" http_request.allowed_domains (comma-separated, '*' allows all)")
|
||||
.allow_empty(true)
|
||||
.default("api.github.com,api.linear.app,calendar.googleapis.com".to_string())
|
||||
.interact_text()?;
|
||||
let domains = parse_allowed_domains_csv(&raw);
|
||||
if domains.is_empty() {
|
||||
anyhow::bail!(
|
||||
"Custom domain list cannot be empty. Use 'Allow all public domains (*)' if that is intended."
|
||||
)
|
||||
} else {
|
||||
Ok(domains)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let prompt = format!(
|
||||
" {}.allowed_domains (comma-separated, '*' allows all)",
|
||||
tool_name
|
||||
@ -3094,12 +3153,7 @@ fn prompt_allowed_domains_for_tool(tool_name: &str) -> Result<Vec<String>> {
|
||||
.default("*".to_string())
|
||||
.interact_text()?;
|
||||
|
||||
let domains: Vec<String> = raw
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let domains = parse_allowed_domains_csv(&raw);
|
||||
|
||||
if domains.is_empty() {
|
||||
Ok(vec!["*".to_string()])
|
||||
@ -3108,6 +3162,149 @@ fn prompt_allowed_domains_for_tool(tool_name: &str) -> Result<Vec<String>> {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_env_var_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c == '_' || c.is_ascii_alphabetic() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn normalize_http_request_profile_name(name: &str) -> String {
|
||||
let normalized = name
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect::<String>();
|
||||
normalized.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn default_env_var_for_profile(profile_name: &str) -> String {
|
||||
match profile_name {
|
||||
"github" => "GITHUB_TOKEN".to_string(),
|
||||
"linear" => "LINEAR_API_KEY".to_string(),
|
||||
"google" => "GOOGLE_API_KEY".to_string(),
|
||||
_ => format!(
|
||||
"{}_TOKEN",
|
||||
profile_name
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_uppercase()
|
||||
} else {
|
||||
'_'
|
||||
})
|
||||
.collect::<String>()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_http_request_credential_profiles(
|
||||
http_request_config: &mut HttpRequestConfig,
|
||||
) -> Result<()> {
|
||||
println!();
|
||||
print_bullet("Optional: configure env-backed credential profiles for http_request.");
|
||||
print_bullet(
|
||||
"This avoids passing raw tokens in tool arguments (use credential_profile instead).",
|
||||
);
|
||||
|
||||
let configure_profiles = Confirm::new()
|
||||
.with_prompt(" Configure HTTP credential profiles now?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !configure_profiles {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
loop {
|
||||
let default_name = if http_request_config.credential_profiles.is_empty() {
|
||||
"github".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"profile-{}",
|
||||
http_request_config.credential_profiles.len() + 1
|
||||
)
|
||||
};
|
||||
let raw_name: String = Input::new()
|
||||
.with_prompt(" Profile name (e.g., github, linear)")
|
||||
.default(default_name)
|
||||
.interact_text()?;
|
||||
let profile_name = normalize_http_request_profile_name(&raw_name);
|
||||
if profile_name.is_empty() {
|
||||
anyhow::bail!("Credential profile name must contain letters, numbers, '_' or '-'");
|
||||
}
|
||||
if http_request_config
|
||||
.credential_profiles
|
||||
.contains_key(&profile_name)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Credential profile '{}' normalizes to '{}' which already exists. Choose a different profile name.",
|
||||
raw_name,
|
||||
profile_name
|
||||
);
|
||||
}
|
||||
|
||||
let env_var_default = default_env_var_for_profile(&profile_name);
|
||||
let env_var_raw: String = Input::new()
|
||||
.with_prompt(" Environment variable containing token/secret")
|
||||
.default(env_var_default)
|
||||
.interact_text()?;
|
||||
let env_var = env_var_raw.trim().to_string();
|
||||
if !is_valid_env_var_name(&env_var) {
|
||||
anyhow::bail!(
|
||||
"Invalid environment variable name: {env_var}. Expected [A-Za-z_][A-Za-z0-9_]*"
|
||||
);
|
||||
}
|
||||
|
||||
let header_name: String = Input::new()
|
||||
.with_prompt(" Header name")
|
||||
.default("Authorization".to_string())
|
||||
.interact_text()?;
|
||||
let header_name = header_name.trim().to_string();
|
||||
if header_name.is_empty() {
|
||||
anyhow::bail!("Header name must not be empty");
|
||||
}
|
||||
|
||||
let value_prefix: String = Input::new()
|
||||
.with_prompt(" Header value prefix (e.g., 'Bearer ', empty for raw token)")
|
||||
.allow_empty(true)
|
||||
.default("Bearer ".to_string())
|
||||
.interact_text()?;
|
||||
|
||||
http_request_config.credential_profiles.insert(
|
||||
profile_name.clone(),
|
||||
HttpRequestCredentialProfile {
|
||||
header_name,
|
||||
env_var,
|
||||
value_prefix,
|
||||
},
|
||||
);
|
||||
|
||||
println!(
|
||||
" {} Added credential profile: {}",
|
||||
style("✓").green().bold(),
|
||||
style(profile_name).green()
|
||||
);
|
||||
|
||||
let add_another = Confirm::new()
|
||||
.with_prompt(" Add another credential profile?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
if !add_another {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Step 6: Web & Internet Tools ────────────────────────────────
|
||||
|
||||
fn setup_web_tools() -> Result<(WebSearchConfig, WebFetchConfig, HttpRequestConfig)> {
|
||||
@ -3261,11 +3458,28 @@ fn setup_web_tools() -> Result<(WebSearchConfig, WebFetchConfig, HttpRequestConf
|
||||
if enable_http_request {
|
||||
http_request_config.enabled = true;
|
||||
http_request_config.allowed_domains = prompt_allowed_domains_for_tool("http_request")?;
|
||||
setup_http_request_credential_profiles(&mut http_request_config)?;
|
||||
println!(
|
||||
" {} http_request.allowed_domains = [{}]",
|
||||
style("✓").green().bold(),
|
||||
style(http_request_config.allowed_domains.join(", ")).green()
|
||||
);
|
||||
if !http_request_config.credential_profiles.is_empty() {
|
||||
let mut names: Vec<String> = http_request_config
|
||||
.credential_profiles
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
names.sort();
|
||||
println!(
|
||||
" {} http_request.credential_profiles = [{}]",
|
||||
style("✓").green().bold(),
|
||||
style(names.join(", ")).green()
|
||||
);
|
||||
print_bullet(
|
||||
"Use tool arg `credential_profile` (for example `github`) instead of raw Authorization headers.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" {} http_request: {}",
|
||||
@ -4037,6 +4251,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
||||
mention_only: false,
|
||||
group_reply: None,
|
||||
base_url: None,
|
||||
ack_enabled: true,
|
||||
});
|
||||
}
|
||||
ChannelMenuChoice::Discord => {
|
||||
@ -8067,6 +8282,36 @@ mod tests {
|
||||
assert!(!provider_supports_device_flow("openrouter"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_productivity_allowed_domains_include_common_integrations() {
|
||||
let domains = http_request_productivity_allowed_domains();
|
||||
assert!(domains.iter().any(|d| d == "api.github.com"));
|
||||
assert!(domains.iter().any(|d| d == "api.linear.app"));
|
||||
assert!(domains.iter().any(|d| d == "calendar.googleapis.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_http_request_profile_name_sanitizes_input() {
|
||||
assert_eq!(
|
||||
normalize_http_request_profile_name(" GitHub Main "),
|
||||
"github-main"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_http_request_profile_name("LINEAR_API"),
|
||||
"linear_api"
|
||||
);
|
||||
assert_eq!(normalize_http_request_profile_name("!!!"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_valid_env_var_name_accepts_and_rejects_expected_patterns() {
|
||||
assert!(is_valid_env_var_name("GITHUB_TOKEN"));
|
||||
assert!(is_valid_env_var_name("_PRIVATE_KEY"));
|
||||
assert!(!is_valid_env_var_name("1BAD"));
|
||||
assert!(!is_valid_env_var_name("BAD-NAME"));
|
||||
assert!(!is_valid_env_var_name("BAD NAME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_provider_choices_include_sglang() {
|
||||
let choices = local_provider_choices();
|
||||
|
||||
@ -458,6 +458,7 @@ impl AnthropicProvider {
|
||||
tool_calls,
|
||||
usage,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -551,8 +552,14 @@ impl Provider for AnthropicProvider {
|
||||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
// Extract quota metadata from response headers before consuming body
|
||||
let quota_extractor = super::quota_adapter::UniversalQuotaExtractor::new();
|
||||
let quota_metadata = quota_extractor.extract("anthropic", response.headers(), None);
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
Ok(Self::parse_native_response(native_response))
|
||||
let mut result = Self::parse_native_response(native_response);
|
||||
result.quota_metadata = quota_metadata;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
|
||||
207
src/providers/backoff.rs
Normal file
207
src/providers/backoff.rs
Normal file
@ -0,0 +1,207 @@
|
||||
//! Generic backoff storage with automatic cleanup.
|
||||
//!
|
||||
//! Thread-safe, in-memory, with TTL-based expiration and soonest-to-expire eviction.
|
||||
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Entry in backoff store with deadline and error context.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackoffEntry<T> {
|
||||
pub deadline: Instant,
|
||||
pub error_detail: T,
|
||||
}
|
||||
|
||||
/// Generic backoff store with automatic cleanup.
|
||||
///
|
||||
/// Thread-safe via parking_lot::Mutex.
|
||||
/// Cleanup strategies:
|
||||
/// - Lazy removal on `get()` if expired
|
||||
/// - Opportunistic cleanup before eviction
|
||||
/// - Soonest-to-expire eviction when max_entries reached (evicts the entry with the smallest deadline)
|
||||
pub struct BackoffStore<K, T> {
|
||||
data: Mutex<HashMap<K, BackoffEntry<T>>>,
|
||||
max_entries: usize,
|
||||
}
|
||||
|
||||
impl<K, T> BackoffStore<K, T>
|
||||
where
|
||||
K: Eq + Hash + Clone,
|
||||
T: Clone,
|
||||
{
|
||||
/// Create new backoff store with capacity limit.
|
||||
pub fn new(max_entries: usize) -> Self {
|
||||
Self {
|
||||
data: Mutex::new(HashMap::new()),
|
||||
max_entries: max_entries.max(1), // Clamp to minimum 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if key is in backoff. Returns remaining duration and error detail.
|
||||
///
|
||||
/// Lazy cleanup: expired entries removed on check.
|
||||
pub fn get(&self, key: &K) -> Option<(Duration, T)> {
|
||||
let mut data = self.data.lock();
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(entry) = data.get(key) {
|
||||
if now >= entry.deadline {
|
||||
// Expired - remove and return None
|
||||
data.remove(key);
|
||||
None
|
||||
} else {
|
||||
let remaining = entry.deadline - now;
|
||||
Some((remaining, entry.error_detail.clone()))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record backoff for key with duration and error context.
|
||||
pub fn set(&self, key: K, duration: Duration, error_detail: T) {
|
||||
let mut data = self.data.lock();
|
||||
let now = Instant::now();
|
||||
|
||||
// Opportunistic cleanup before eviction
|
||||
if data.len() >= self.max_entries {
|
||||
data.retain(|_, entry| entry.deadline > now);
|
||||
}
|
||||
|
||||
// Soonest-to-expire eviction if still over capacity
|
||||
if data.len() >= self.max_entries {
|
||||
if let Some(oldest_key) = data
|
||||
.iter()
|
||||
.min_by_key(|(_, entry)| entry.deadline)
|
||||
.map(|(k, _)| k.clone())
|
||||
{
|
||||
data.remove(&oldest_key);
|
||||
}
|
||||
}
|
||||
|
||||
data.insert(
|
||||
key,
|
||||
BackoffEntry {
|
||||
deadline: now + duration,
|
||||
error_detail,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear backoff for key (on success).
|
||||
pub fn clear(&self, key: &K) {
|
||||
self.data.lock().remove(key);
|
||||
}
|
||||
|
||||
/// Clear all backoffs (for testing).
|
||||
#[cfg(test)]
|
||||
pub fn clear_all(&self) {
|
||||
self.data.lock().clear();
|
||||
}
|
||||
|
||||
/// Get count of active backoffs (for observability).
|
||||
pub fn len(&self) -> usize {
|
||||
let mut data = self.data.lock();
|
||||
let now = Instant::now();
|
||||
data.retain(|_, entry| entry.deadline > now);
|
||||
data.len()
|
||||
}
|
||||
|
||||
/// Check if store is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn backoff_stores_and_retrieves_entry() {
|
||||
let store = BackoffStore::new(10);
|
||||
let key = "test-key";
|
||||
let error = "test error";
|
||||
|
||||
store.set(key.to_string(), Duration::from_secs(5), error.to_string());
|
||||
|
||||
let result = store.get(&key.to_string());
|
||||
assert!(result.is_some());
|
||||
|
||||
let (remaining, stored_error) = result.unwrap();
|
||||
assert!(remaining.as_secs() > 0 && remaining.as_secs() <= 5);
|
||||
assert_eq!(stored_error, error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_expires_after_duration() {
|
||||
let store = BackoffStore::new(10);
|
||||
let key = "expire-test";
|
||||
|
||||
store.set(
|
||||
key.to_string(),
|
||||
Duration::from_millis(50),
|
||||
"error".to_string(),
|
||||
);
|
||||
assert!(store.get(&key.to_string()).is_some());
|
||||
|
||||
thread::sleep(Duration::from_millis(60));
|
||||
assert!(store.get(&key.to_string()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_clears_on_demand() {
|
||||
let store = BackoffStore::new(10);
|
||||
let key = "clear-test";
|
||||
|
||||
store.set(
|
||||
key.to_string(),
|
||||
Duration::from_secs(10),
|
||||
"error".to_string(),
|
||||
);
|
||||
assert!(store.get(&key.to_string()).is_some());
|
||||
|
||||
store.clear(&key.to_string());
|
||||
assert!(store.get(&key.to_string()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_lru_eviction_at_capacity() {
|
||||
let store = BackoffStore::new(2);
|
||||
|
||||
store.set(
|
||||
"key1".to_string(),
|
||||
Duration::from_secs(10),
|
||||
"error1".to_string(),
|
||||
);
|
||||
store.set(
|
||||
"key2".to_string(),
|
||||
Duration::from_secs(20),
|
||||
"error2".to_string(),
|
||||
);
|
||||
store.set(
|
||||
"key3".to_string(),
|
||||
Duration::from_secs(30),
|
||||
"error3".to_string(),
|
||||
);
|
||||
|
||||
// key1 should be evicted (shortest deadline)
|
||||
assert!(store.get(&"key1".to_string()).is_none());
|
||||
assert!(store.get(&"key2".to_string()).is_some());
|
||||
assert!(store.get(&"key3".to_string()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_max_entries_clamped_to_one() {
|
||||
let store = BackoffStore::new(0); // Should clamp to 1
|
||||
store.set(
|
||||
"only-key".to_string(),
|
||||
Duration::from_secs(5),
|
||||
"error".to_string(),
|
||||
);
|
||||
assert!(store.get(&"only-key".to_string()).is_some());
|
||||
}
|
||||
}
|
||||
@ -882,6 +882,7 @@ impl BedrockProvider {
|
||||
tool_calls,
|
||||
usage,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -936,6 +936,7 @@ fn parse_responses_chat_response(response: ResponsesResponse) -> ProviderChatRes
|
||||
tool_calls,
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1578,6 +1579,7 @@ impl OpenAiCompatibleProvider {
|
||||
tool_calls,
|
||||
usage: None,
|
||||
reasoning_content,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1946,6 +1948,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -2001,6 +2004,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
tool_calls,
|
||||
usage,
|
||||
reasoning_content,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -2097,6 +2101,7 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -313,6 +313,43 @@ impl CopilotProvider {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn merge_response_choices(
|
||||
choices: Vec<Choice>,
|
||||
) -> anyhow::Result<(Option<String>, Vec<ProviderToolCall>)> {
|
||||
if choices.is_empty() {
|
||||
return Err(anyhow::anyhow!("No response from GitHub Copilot"));
|
||||
}
|
||||
|
||||
// Keep the first non-empty text response and aggregate tool calls from every choice.
|
||||
let mut text = None;
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for choice in choices {
|
||||
let ResponseMessage {
|
||||
content,
|
||||
tool_calls: choice_tool_calls,
|
||||
} = choice.message;
|
||||
|
||||
if text.is_none() {
|
||||
if let Some(content) = content.filter(|value| !value.is_empty()) {
|
||||
text = Some(content);
|
||||
}
|
||||
}
|
||||
|
||||
for tool_call in choice_tool_calls.unwrap_or_default() {
|
||||
tool_calls.push(ProviderToolCall {
|
||||
id: tool_call
|
||||
.id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tool_call.function.name,
|
||||
arguments: tool_call.function.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok((text, tool_calls))
|
||||
}
|
||||
|
||||
/// Send a chat completions request with required Copilot headers.
|
||||
async fn send_chat_request(
|
||||
&self,
|
||||
@ -354,31 +391,15 @@ impl CopilotProvider {
|
||||
input_tokens: u.prompt_tokens,
|
||||
output_tokens: u.completion_tokens,
|
||||
});
|
||||
let choice = api_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from GitHub Copilot"))?;
|
||||
|
||||
let tool_calls = choice
|
||||
.message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tool_call| ProviderToolCall {
|
||||
id: tool_call
|
||||
.id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tool_call.function.name,
|
||||
arguments: tool_call.function.arguments,
|
||||
})
|
||||
.collect();
|
||||
// Copilot may split text and tool calls across multiple choices.
|
||||
let (text, tool_calls) = Self::merge_response_choices(api_response.choices)?;
|
||||
|
||||
Ok(ProviderChatResponse {
|
||||
text: choice.message.content,
|
||||
text,
|
||||
tool_calls,
|
||||
usage,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -735,4 +756,79 @@ mod tests {
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.usage.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_choices_merges_tool_calls_across_choices() {
|
||||
let choices = vec![
|
||||
Choice {
|
||||
message: ResponseMessage {
|
||||
content: Some("Let me check".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
},
|
||||
Choice {
|
||||
message: ResponseMessage {
|
||||
content: None,
|
||||
tool_calls: Some(vec![
|
||||
NativeToolCall {
|
||||
id: Some("tool-1".to_string()),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: "get_time".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
},
|
||||
NativeToolCall {
|
||||
id: Some("tool-2".to_string()),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: "read_file".to_string(),
|
||||
arguments: r#"{"path":"notes.txt"}"#.to_string(),
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let (text, tool_calls) = CopilotProvider::merge_response_choices(choices).unwrap();
|
||||
assert_eq!(text.as_deref(), Some("Let me check"));
|
||||
assert_eq!(tool_calls.len(), 2);
|
||||
assert_eq!(tool_calls[0].id, "tool-1");
|
||||
assert_eq!(tool_calls[1].id, "tool-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_choices_prefers_first_non_empty_text() {
|
||||
let choices = vec![
|
||||
Choice {
|
||||
message: ResponseMessage {
|
||||
content: Some(String::new()),
|
||||
tool_calls: None,
|
||||
},
|
||||
},
|
||||
Choice {
|
||||
message: ResponseMessage {
|
||||
content: Some("First".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
},
|
||||
Choice {
|
||||
message: ResponseMessage {
|
||||
content: Some("Second".to_string()),
|
||||
tool_calls: None,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let (text, tool_calls) = CopilotProvider::merge_response_choices(choices).unwrap();
|
||||
assert_eq!(text.as_deref(), Some("First"));
|
||||
assert!(tool_calls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_response_choices_rejects_empty_choice_list() {
|
||||
let error = CopilotProvider::merge_response_choices(Vec::new()).unwrap_err();
|
||||
assert!(error.to_string().contains("No response"));
|
||||
}
|
||||
}
|
||||
|
||||
333
src/providers/cursor.rs
Normal file
333
src/providers/cursor.rs
Normal file
@ -0,0 +1,333 @@
|
||||
//! Cursor headless non-interactive CLI provider.
|
||||
//!
|
||||
//! Integrates with Cursor's headless CLI mode, spawning the `cursor` binary
|
||||
//! as a subprocess for each inference request. This allows using Cursor's AI
|
||||
//! models without an interactive UI session.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! The `cursor` binary must be available in `PATH`, or its location must be
|
||||
//! set via the `CURSOR_PATH` environment variable.
|
||||
//!
|
||||
//! Cursor is invoked as:
|
||||
//! ```text
|
||||
//! cursor --headless --model <model> -
|
||||
//! ```
|
||||
//! with prompt content written to stdin.
|
||||
//!
|
||||
//! If the model argument is `"default"` or empty, the `--model` flag is omitted
|
||||
//! and Cursor's own default model is used.
|
||||
//!
|
||||
//! # Limitations
|
||||
//!
|
||||
//! - **Conversation history**: Only the system prompt (if present) and the last
|
||||
//! user message are forwarded. Full multi-turn history is not preserved because
|
||||
//! Cursor's headless CLI accepts a single prompt per invocation.
|
||||
//! - **System prompt**: The system prompt is prepended to the user message with a
|
||||
//! blank-line separator, as the headless CLI does not provide a dedicated
|
||||
//! system-prompt flag.
|
||||
//! - **Temperature**: Cursor's headless CLI does not expose a temperature parameter.
|
||||
//! Only default values are accepted; custom values return an explicit error.
|
||||
//!
|
||||
//! # Authentication
|
||||
//!
|
||||
//! Authentication is handled by Cursor itself (its own credential store).
|
||||
//! No explicit API key is required by this provider.
|
||||
//!
|
||||
//! # Environment variables
|
||||
//!
|
||||
//! - `CURSOR_PATH` — override the path to the `cursor` binary (default: `"cursor"`)
|
||||
|
||||
use crate::providers::traits::{ChatRequest, ChatResponse, Provider, TokenUsage};
|
||||
use async_trait::async_trait;
|
||||
use std::path::PathBuf;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
/// Environment variable for overriding the path to the `cursor` binary.
|
||||
pub const CURSOR_PATH_ENV: &str = "CURSOR_PATH";
|
||||
|
||||
/// Default `cursor` binary name (resolved via `PATH`).
|
||||
const DEFAULT_CURSOR_BINARY: &str = "cursor";
|
||||
|
||||
/// Model name used to signal "use Cursor's own default model".
|
||||
const DEFAULT_MODEL_MARKER: &str = "default";
|
||||
/// Cursor requests are bounded to avoid hung subprocesses.
|
||||
const CURSOR_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Avoid leaking oversized stderr payloads.
|
||||
const MAX_CURSOR_STDERR_CHARS: usize = 512;
|
||||
/// Cursor does not support sampling controls; allow only baseline defaults.
|
||||
const CURSOR_SUPPORTED_TEMPERATURES: [f64; 2] = [0.7, 1.0];
|
||||
const TEMP_EPSILON: f64 = 1e-9;
|
||||
|
||||
/// Provider that invokes the Cursor headless CLI as a subprocess.
|
||||
///
|
||||
/// Each inference request spawns a fresh `cursor` process. This is the
|
||||
/// non-interactive approach: Cursor processes the prompt and exits.
|
||||
pub struct CursorProvider {
|
||||
/// Path to the `cursor` binary.
|
||||
cursor_path: PathBuf,
|
||||
}
|
||||
|
||||
impl CursorProvider {
|
||||
/// Create a new `CursorProvider`.
|
||||
///
|
||||
/// The binary path is resolved from `CURSOR_PATH` env var if set,
|
||||
/// otherwise defaults to `"cursor"` (found via `PATH`).
|
||||
pub fn new() -> Self {
|
||||
let cursor_path = std::env::var(CURSOR_PATH_ENV)
|
||||
.ok()
|
||||
.filter(|path| !path.trim().is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(DEFAULT_CURSOR_BINARY));
|
||||
|
||||
Self { cursor_path }
|
||||
}
|
||||
|
||||
/// Returns true if the model argument should be forwarded to cursor.
|
||||
fn should_forward_model(model: &str) -> bool {
|
||||
let trimmed = model.trim();
|
||||
!trimmed.is_empty() && trimmed != DEFAULT_MODEL_MARKER
|
||||
}
|
||||
|
||||
fn supports_temperature(temperature: f64) -> bool {
|
||||
CURSOR_SUPPORTED_TEMPERATURES
|
||||
.iter()
|
||||
.any(|v| (temperature - v).abs() < TEMP_EPSILON)
|
||||
}
|
||||
|
||||
fn validate_temperature(temperature: f64) -> anyhow::Result<()> {
|
||||
if !temperature.is_finite() {
|
||||
anyhow::bail!("Cursor provider received non-finite temperature value");
|
||||
}
|
||||
if !Self::supports_temperature(temperature) {
|
||||
anyhow::bail!(
|
||||
"temperature unsupported by Cursor headless CLI: {temperature}. \
|
||||
Supported values: 0.7 or 1.0"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn redact_stderr(stderr: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(stderr);
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if trimmed.chars().count() <= MAX_CURSOR_STDERR_CHARS {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let clipped: String = trimmed.chars().take(MAX_CURSOR_STDERR_CHARS).collect();
|
||||
format!("{clipped}...")
|
||||
}
|
||||
|
||||
/// Invoke the cursor binary with the given prompt and optional model.
|
||||
/// Returns the trimmed stdout output as the assistant response.
|
||||
async fn invoke_cursor(&self, message: &str, model: &str) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new(&self.cursor_path);
|
||||
cmd.arg("--headless");
|
||||
|
||||
if Self::should_forward_model(model) {
|
||||
cmd.arg("--model").arg(model);
|
||||
}
|
||||
|
||||
// Read prompt from stdin to avoid exposing sensitive content in process args.
|
||||
cmd.arg("-");
|
||||
cmd.kill_on_drop(true);
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to spawn Cursor binary at {:?}: {err}. \
|
||||
Ensure `cursor` is installed and in PATH, or set CURSOR_PATH.",
|
||||
self.cursor_path
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin
|
||||
.write_all(message.as_bytes())
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("Failed to write prompt to Cursor stdin: {err}"))?;
|
||||
stdin
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("Failed to finalize Cursor stdin stream: {err}"))?;
|
||||
}
|
||||
|
||||
let output = timeout(CURSOR_REQUEST_TIMEOUT, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Cursor request timed out after {:?} (binary: {:?})",
|
||||
CURSOR_REQUEST_TIMEOUT,
|
||||
self.cursor_path
|
||||
)
|
||||
})?
|
||||
.map_err(|err| anyhow::anyhow!("Cursor process failed: {err}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
let stderr_excerpt = Self::redact_stderr(&output.stderr);
|
||||
let stderr_note = if stderr_excerpt.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" Stderr: {stderr_excerpt}")
|
||||
};
|
||||
anyhow::bail!(
|
||||
"Cursor exited with non-zero status {code}. \
|
||||
Check that Cursor is authenticated and the headless CLI is supported.{stderr_note}"
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8(output.stdout)
|
||||
.map_err(|err| anyhow::anyhow!("Cursor produced non-UTF-8 output: {err}"))?;
|
||||
|
||||
Ok(text.trim().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CursorProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for CursorProvider {
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<String> {
|
||||
Self::validate_temperature(temperature)?;
|
||||
|
||||
// Prepend the system prompt to the user message with a blank-line separator.
|
||||
// Cursor's headless CLI does not expose a dedicated system-prompt flag.
|
||||
let full_message = match system_prompt {
|
||||
Some(system) if !system.is_empty() => {
|
||||
format!("{system}\n\n{message}")
|
||||
}
|
||||
_ => message.to_string(),
|
||||
};
|
||||
|
||||
self.invoke_cursor(&full_message, model).await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let text = self
|
||||
.chat_with_history(request.messages, model, temperature)
|
||||
.await?;
|
||||
|
||||
Ok(ChatResponse {
|
||||
text: Some(text),
|
||||
tool_calls: Vec::new(),
|
||||
usage: Some(TokenUsage::default()),
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock poisoned")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_uses_env_override() {
|
||||
let _guard = env_lock();
|
||||
let orig = std::env::var(CURSOR_PATH_ENV).ok();
|
||||
std::env::set_var(CURSOR_PATH_ENV, "/usr/local/bin/cursor");
|
||||
let provider = CursorProvider::new();
|
||||
assert_eq!(provider.cursor_path, PathBuf::from("/usr/local/bin/cursor"));
|
||||
match orig {
|
||||
Some(v) => std::env::set_var(CURSOR_PATH_ENV, v),
|
||||
None => std::env::remove_var(CURSOR_PATH_ENV),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_defaults_to_cursor() {
|
||||
let _guard = env_lock();
|
||||
let orig = std::env::var(CURSOR_PATH_ENV).ok();
|
||||
std::env::remove_var(CURSOR_PATH_ENV);
|
||||
let provider = CursorProvider::new();
|
||||
assert_eq!(provider.cursor_path, PathBuf::from("cursor"));
|
||||
if let Some(v) = orig {
|
||||
std::env::set_var(CURSOR_PATH_ENV, v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ignores_blank_env_override() {
|
||||
let _guard = env_lock();
|
||||
let orig = std::env::var(CURSOR_PATH_ENV).ok();
|
||||
std::env::set_var(CURSOR_PATH_ENV, " ");
|
||||
let provider = CursorProvider::new();
|
||||
assert_eq!(provider.cursor_path, PathBuf::from("cursor"));
|
||||
match orig {
|
||||
Some(v) => std::env::set_var(CURSOR_PATH_ENV, v),
|
||||
None => std::env::remove_var(CURSOR_PATH_ENV),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_forward_model_standard() {
|
||||
assert!(CursorProvider::should_forward_model("claude-3.5-sonnet"));
|
||||
assert!(CursorProvider::should_forward_model("gpt-4o"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_forward_default_model() {
|
||||
assert!(!CursorProvider::should_forward_model(DEFAULT_MODEL_MARKER));
|
||||
assert!(!CursorProvider::should_forward_model(""));
|
||||
assert!(!CursorProvider::should_forward_model(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_temperature_allows_defaults() {
|
||||
assert!(CursorProvider::validate_temperature(0.7).is_ok());
|
||||
assert!(CursorProvider::validate_temperature(1.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_temperature_rejects_custom_value() {
|
||||
let err = CursorProvider::validate_temperature(0.2).unwrap_err();
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("temperature unsupported by Cursor headless CLI"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invoke_missing_binary_returns_error() {
|
||||
let provider = CursorProvider {
|
||||
cursor_path: PathBuf::from("/nonexistent/path/to/cursor"),
|
||||
};
|
||||
let result = provider.invoke_cursor("hello", "gpt-4o").await;
|
||||
assert!(result.is_err());
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
msg.contains("Failed to spawn Cursor binary"),
|
||||
"unexpected error message: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1272,6 +1272,7 @@ impl Provider for GeminiProvider {
|
||||
tool_calls: Vec::new(),
|
||||
usage,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
274
src/providers/health.rs
Normal file
274
src/providers/health.rs
Normal file
@ -0,0 +1,274 @@
|
||||
//! Provider health tracking with circuit breaker pattern.
|
||||
//!
|
||||
//! Tracks provider failure counts and temporarily blocks providers that exceed
|
||||
//! failure thresholds (circuit breaker pattern). Uses separate storage for:
|
||||
//! - Persistent failure state (HashMap with failure counts)
|
||||
//! - Temporary circuit breaker blocks (BackoffStore with TTL)
|
||||
|
||||
use super::backoff::BackoffStore;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Provider health state with failure tracking.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct ProviderHealthState {
|
||||
pub failure_count: u32,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Thread-safe provider health tracker with circuit breaker.
|
||||
///
|
||||
/// Architecture:
|
||||
/// - `states`: Persistent failure counts per provider (never expires)
|
||||
/// - `backoff`: Temporary circuit breaker blocks with TTL (auto-expires)
|
||||
///
|
||||
/// This separation ensures:
|
||||
/// - Circuit breaker blocks expire after cooldown (backoff.get() returns None)
|
||||
/// - Failure history persists for observability (states HashMap)
|
||||
pub struct ProviderHealthTracker {
|
||||
/// Persistent failure state per provider
|
||||
states: Arc<Mutex<HashMap<String, ProviderHealthState>>>,
|
||||
/// Temporary circuit breaker blocks with TTL
|
||||
backoff: Arc<BackoffStore<String, ()>>,
|
||||
/// Failure threshold before circuit opens
|
||||
failure_threshold: u32,
|
||||
/// Circuit breaker cooldown duration
|
||||
cooldown: Duration,
|
||||
}
|
||||
|
||||
impl ProviderHealthTracker {
|
||||
/// Create new health tracker with circuit breaker settings.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `failure_threshold` - Number of consecutive failures before circuit opens
|
||||
/// * `cooldown` - Duration to block provider after circuit opens
|
||||
/// * `max_tracked_providers` - Maximum number of providers to track (for BackoffStore capacity)
|
||||
pub fn new(failure_threshold: u32, cooldown: Duration, max_tracked_providers: usize) -> Self {
|
||||
Self {
|
||||
states: Arc::new(Mutex::new(HashMap::new())),
|
||||
backoff: Arc::new(BackoffStore::new(max_tracked_providers)),
|
||||
failure_threshold,
|
||||
cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if provider should be tried (circuit closed).
|
||||
///
|
||||
/// Returns:
|
||||
/// - `Ok(())` if circuit is closed (provider can be tried)
|
||||
/// - `Err((remaining, state))` if circuit is open (provider blocked)
|
||||
pub fn should_try(&self, provider: &str) -> Result<(), (Duration, ProviderHealthState)> {
|
||||
// Check circuit breaker
|
||||
if let Some((remaining, ())) = self.backoff.get(&provider.to_string()) {
|
||||
// Circuit is open - return remaining time and current state
|
||||
let states = self.states.lock();
|
||||
let state = states.get(provider).cloned().unwrap_or_default();
|
||||
return Err((remaining, state));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record successful provider call.
|
||||
///
|
||||
/// Resets failure count and clears circuit breaker.
|
||||
pub fn record_success(&self, provider: &str) {
|
||||
let mut states = self.states.lock();
|
||||
if let Some(state) = states.get_mut(provider) {
|
||||
if state.failure_count > 0 {
|
||||
tracing::info!(
|
||||
provider = provider,
|
||||
previous_failures = state.failure_count,
|
||||
"Provider recovered - resetting failure count"
|
||||
);
|
||||
state.failure_count = 0;
|
||||
state.last_error = None;
|
||||
}
|
||||
}
|
||||
drop(states);
|
||||
|
||||
// Clear circuit breaker
|
||||
self.backoff.clear(&provider.to_string());
|
||||
}
|
||||
|
||||
/// Record failed provider call.
|
||||
///
|
||||
/// Increments failure count. If threshold exceeded, opens circuit breaker.
|
||||
pub fn record_failure(&self, provider: &str, error: &str) {
|
||||
let mut states = self.states.lock();
|
||||
let state = states.entry(provider.to_string()).or_default();
|
||||
|
||||
state.failure_count += 1;
|
||||
state.last_error = Some(error.to_string());
|
||||
|
||||
let current_count = state.failure_count;
|
||||
drop(states);
|
||||
|
||||
// Open circuit if threshold exceeded
|
||||
if current_count >= self.failure_threshold {
|
||||
tracing::warn!(
|
||||
provider = provider,
|
||||
failure_count = current_count,
|
||||
threshold = self.failure_threshold,
|
||||
cooldown_secs = self.cooldown.as_secs(),
|
||||
"Provider failure threshold exceeded - opening circuit breaker"
|
||||
);
|
||||
self.backoff.set(provider.to_string(), self.cooldown, ());
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current health state for a provider.
|
||||
pub fn get_state(&self, provider: &str) -> ProviderHealthState {
|
||||
self.states
|
||||
.lock()
|
||||
.get(provider)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all tracked provider states (for observability).
|
||||
pub fn get_all_states(&self) -> HashMap<String, ProviderHealthState> {
|
||||
self.states.lock().clone()
|
||||
}
|
||||
|
||||
/// Clear all health tracking (for testing).
|
||||
#[cfg(test)]
|
||||
pub fn clear_all(&self) {
|
||||
self.states.lock().clear();
|
||||
self.backoff.clear_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::thread;
|
||||
|
||||
#[test]
|
||||
fn allows_provider_initially() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_secs(60), 100);
|
||||
assert!(tracker.should_try("test-provider").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_failures_below_threshold() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_secs(60), 100);
|
||||
|
||||
tracker.record_failure("test-provider", "error 1");
|
||||
assert!(tracker.should_try("test-provider").is_ok());
|
||||
|
||||
tracker.record_failure("test-provider", "error 2");
|
||||
assert!(tracker.should_try("test-provider").is_ok());
|
||||
|
||||
let state = tracker.get_state("test-provider");
|
||||
assert_eq!(state.failure_count, 2);
|
||||
assert_eq!(state.last_error.as_deref(), Some("error 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_circuit_at_threshold() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_secs(60), 100);
|
||||
|
||||
tracker.record_failure("test-provider", "error 1");
|
||||
tracker.record_failure("test-provider", "error 2");
|
||||
tracker.record_failure("test-provider", "error 3");
|
||||
|
||||
// Circuit should be open
|
||||
let result = tracker.should_try("test-provider");
|
||||
assert!(result.is_err());
|
||||
|
||||
if let Err((remaining, state)) = result {
|
||||
assert!(remaining.as_secs() > 0 && remaining.as_secs() <= 60);
|
||||
assert_eq!(state.failure_count, 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circuit_closes_after_cooldown() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_millis(50), 100);
|
||||
|
||||
// Trigger circuit breaker
|
||||
tracker.record_failure("test-provider", "error 1");
|
||||
tracker.record_failure("test-provider", "error 2");
|
||||
tracker.record_failure("test-provider", "error 3");
|
||||
|
||||
assert!(tracker.should_try("test-provider").is_err());
|
||||
|
||||
// Wait for cooldown
|
||||
thread::sleep(Duration::from_millis(60));
|
||||
|
||||
// Circuit should be closed (backoff expired)
|
||||
assert!(tracker.should_try("test-provider").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_resets_failure_count() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_secs(60), 100);
|
||||
|
||||
tracker.record_failure("test-provider", "error 1");
|
||||
tracker.record_failure("test-provider", "error 2");
|
||||
|
||||
assert_eq!(tracker.get_state("test-provider").failure_count, 2);
|
||||
|
||||
tracker.record_success("test-provider");
|
||||
|
||||
let state = tracker.get_state("test-provider");
|
||||
assert_eq!(state.failure_count, 0);
|
||||
assert_eq!(state.last_error, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_clears_circuit_breaker() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_secs(60), 100);
|
||||
|
||||
// Trigger circuit breaker
|
||||
tracker.record_failure("test-provider", "error 1");
|
||||
tracker.record_failure("test-provider", "error 2");
|
||||
tracker.record_failure("test-provider", "error 3");
|
||||
|
||||
assert!(tracker.should_try("test-provider").is_err());
|
||||
|
||||
// Success should clear circuit immediately
|
||||
tracker.record_success("test-provider");
|
||||
|
||||
assert!(tracker.should_try("test-provider").is_ok());
|
||||
assert_eq!(tracker.get_state("test-provider").failure_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_multiple_providers_independently() {
|
||||
let tracker = ProviderHealthTracker::new(2, Duration::from_secs(60), 100);
|
||||
|
||||
tracker.record_failure("provider-a", "error a1");
|
||||
tracker.record_failure("provider-a", "error a2");
|
||||
|
||||
tracker.record_failure("provider-b", "error b1");
|
||||
|
||||
// Provider A should have circuit open
|
||||
assert!(tracker.should_try("provider-a").is_err());
|
||||
|
||||
// Provider B should still be allowed
|
||||
assert!(tracker.should_try("provider-b").is_ok());
|
||||
|
||||
let state_a = tracker.get_state("provider-a");
|
||||
let state_b = tracker.get_state("provider-b");
|
||||
assert_eq!(state_a.failure_count, 2);
|
||||
assert_eq!(state_b.failure_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_all_states_returns_all_tracked_providers() {
|
||||
let tracker = ProviderHealthTracker::new(3, Duration::from_secs(60), 100);
|
||||
|
||||
tracker.record_failure("provider-1", "error 1");
|
||||
tracker.record_failure("provider-2", "error 2");
|
||||
tracker.record_failure("provider-2", "error 2 again");
|
||||
|
||||
let states = tracker.get_all_states();
|
||||
assert_eq!(states.len(), 2);
|
||||
assert_eq!(states.get("provider-1").unwrap().failure_count, 1);
|
||||
assert_eq!(states.get("provider-2").unwrap().failure_count, 2);
|
||||
}
|
||||
}
|
||||
@ -17,14 +17,20 @@
|
||||
//! in [`create_provider_with_url`]. See `AGENTS.md` §7.1 for the full change playbook.
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod backoff;
|
||||
pub mod bedrock;
|
||||
pub mod compatible;
|
||||
pub mod copilot;
|
||||
pub mod cursor;
|
||||
pub mod gemini;
|
||||
pub mod health;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
pub mod openai_codex;
|
||||
pub mod openrouter;
|
||||
pub mod quota_adapter;
|
||||
pub mod quota_cli;
|
||||
pub mod quota_types;
|
||||
pub mod reliable;
|
||||
pub mod router;
|
||||
pub mod telnyx;
|
||||
@ -1233,6 +1239,7 @@ fn create_provider_with_url_and_options(
|
||||
"Cohere", "https://api.cohere.com/compatibility", key, AuthStyle::Bearer,
|
||||
))),
|
||||
"copilot" | "github-copilot" => Ok(Box::new(copilot::CopilotProvider::new(key))),
|
||||
"cursor" => Ok(Box::new(cursor::CursorProvider::new())),
|
||||
"lmstudio" | "lm-studio" => {
|
||||
let lm_studio_key = key
|
||||
.map(str::trim)
|
||||
@ -1505,21 +1512,52 @@ pub fn create_routed_provider_with_options(
|
||||
);
|
||||
}
|
||||
|
||||
// Keep a default provider for non-routed model hints.
|
||||
let default_provider = create_resilient_provider_with_options(
|
||||
let default_hint = default_model
|
||||
.strip_prefix("hint:")
|
||||
.map(str::trim)
|
||||
.filter(|hint| !hint.is_empty());
|
||||
|
||||
let mut providers: Vec<(String, Box<dyn Provider>)> = Vec::new();
|
||||
let mut has_primary_provider = false;
|
||||
|
||||
// Keep a default provider for non-routed requests. When default_model is a hint,
|
||||
// route-specific providers can satisfy startup even if the primary fails.
|
||||
match create_resilient_provider_with_options(
|
||||
primary_name,
|
||||
api_key,
|
||||
api_url,
|
||||
reliability,
|
||||
options,
|
||||
)?;
|
||||
let mut providers: Vec<(String, Box<dyn Provider>)> =
|
||||
vec![(primary_name.to_string(), default_provider)];
|
||||
) {
|
||||
Ok(default_provider) => {
|
||||
providers.push((primary_name.to_string(), default_provider));
|
||||
has_primary_provider = true;
|
||||
}
|
||||
Err(error) => {
|
||||
if default_hint.is_some() {
|
||||
tracing::warn!(
|
||||
provider = primary_name,
|
||||
model = default_model,
|
||||
"Primary provider failed during routed init; continuing with hint-based routes: {error}"
|
||||
);
|
||||
} else {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build hint routes with dedicated provider instances so per-route API keys
|
||||
// and max_tokens overrides do not bleed across routes.
|
||||
let mut routes: Vec<(String, router::Route)> = Vec::new();
|
||||
for route in model_routes {
|
||||
let route_hint = route.hint.trim();
|
||||
if route_hint.is_empty() {
|
||||
tracing::warn!(
|
||||
provider = route.provider.as_str(),
|
||||
"Ignoring routed provider with empty hint"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let routed_credential = route.api_key.as_ref().and_then(|raw_key| {
|
||||
let trimmed_key = raw_key.trim();
|
||||
(!trimmed_key.is_empty()).then_some(trimmed_key)
|
||||
@ -1548,10 +1586,10 @@ pub fn create_routed_provider_with_options(
|
||||
&route_options,
|
||||
) {
|
||||
Ok(provider) => {
|
||||
let provider_id = format!("{}#{}", route.provider, route.hint);
|
||||
let provider_id = format!("{}#{}", route.provider, route_hint);
|
||||
providers.push((provider_id.clone(), provider));
|
||||
routes.push((
|
||||
route.hint.clone(),
|
||||
route_hint.to_string(),
|
||||
router::Route {
|
||||
provider_name: provider_id,
|
||||
model: route.model.clone(),
|
||||
@ -1561,19 +1599,42 @@ pub fn create_routed_provider_with_options(
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = route.provider.as_str(),
|
||||
hint = route.hint.as_str(),
|
||||
hint = route_hint,
|
||||
"Ignoring routed provider that failed to initialize: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(hint) = default_hint {
|
||||
if !routes
|
||||
.iter()
|
||||
.any(|(route_hint, _)| route_hint.trim() == hint)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"default_model uses hint '{hint}', but no matching [[model_routes]] entry initialized successfully"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if providers.is_empty() {
|
||||
anyhow::bail!("No providers initialized for routed configuration");
|
||||
}
|
||||
|
||||
// Keep only successfully initialized routed providers and preserve
|
||||
// their provider-id bindings (e.g. "<provider>#<hint>").
|
||||
|
||||
Ok(Box::new(
|
||||
router::RouterProvider::new(providers, routes, default_model.to_string())
|
||||
.with_vision_override(options.model_support_vision),
|
||||
router::RouterProvider::new(
|
||||
providers,
|
||||
routes,
|
||||
if has_primary_provider {
|
||||
String::new()
|
||||
} else {
|
||||
default_model.to_string()
|
||||
},
|
||||
)
|
||||
.with_vision_override(options.model_support_vision),
|
||||
))
|
||||
}
|
||||
|
||||
@ -1803,6 +1864,12 @@ pub fn list_providers() -> Vec<ProviderInfo> {
|
||||
aliases: &["github-copilot"],
|
||||
local: false,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "cursor",
|
||||
display_name: "Cursor (headless CLI)",
|
||||
aliases: &[],
|
||||
local: true,
|
||||
},
|
||||
ProviderInfo {
|
||||
name: "lmstudio",
|
||||
display_name: "LM Studio",
|
||||
@ -2505,6 +2572,11 @@ mod tests {
|
||||
assert!(create_provider("github-copilot", Some("key")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_cursor() {
|
||||
assert!(create_provider("cursor", None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_nvidia() {
|
||||
assert!(create_provider("nvidia", Some("nvapi-test")).is_ok());
|
||||
@ -2839,6 +2911,7 @@ mod tests {
|
||||
"perplexity",
|
||||
"cohere",
|
||||
"copilot",
|
||||
"cursor",
|
||||
"nvidia",
|
||||
"astrai",
|
||||
"ovhcloud",
|
||||
@ -3106,6 +3179,90 @@ mod tests {
|
||||
assert!(provider.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routed_provider_supports_hint_default_when_primary_init_fails() {
|
||||
let reliability = crate::config::ReliabilityConfig::default();
|
||||
let routes = vec![crate::config::ModelRouteConfig {
|
||||
hint: "reasoning".to_string(),
|
||||
provider: "lmstudio".to_string(),
|
||||
model: "qwen2.5-coder".to_string(),
|
||||
max_tokens: None,
|
||||
api_key: None,
|
||||
transport: None,
|
||||
}];
|
||||
|
||||
let provider = create_routed_provider_with_options(
|
||||
"provider-that-does-not-exist",
|
||||
None,
|
||||
None,
|
||||
&reliability,
|
||||
&routes,
|
||||
"hint:reasoning",
|
||||
&ProviderRuntimeOptions::default(),
|
||||
);
|
||||
assert!(
|
||||
provider.is_ok(),
|
||||
"hint default should allow startup from route providers"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routed_provider_normalizes_whitespace_in_hint_routes() {
|
||||
let reliability = crate::config::ReliabilityConfig::default();
|
||||
let routes = vec![crate::config::ModelRouteConfig {
|
||||
hint: " reasoning ".to_string(),
|
||||
provider: "lmstudio".to_string(),
|
||||
model: "qwen2.5-coder".to_string(),
|
||||
max_tokens: None,
|
||||
api_key: None,
|
||||
transport: None,
|
||||
}];
|
||||
|
||||
let provider = create_routed_provider_with_options(
|
||||
"provider-that-does-not-exist",
|
||||
None,
|
||||
None,
|
||||
&reliability,
|
||||
&routes,
|
||||
"hint: reasoning ",
|
||||
&ProviderRuntimeOptions::default(),
|
||||
);
|
||||
assert!(
|
||||
provider.is_ok(),
|
||||
"trimmed default hint should match trimmed route hint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routed_provider_rejects_unresolved_hint_default() {
|
||||
let reliability = crate::config::ReliabilityConfig::default();
|
||||
let routes = vec![crate::config::ModelRouteConfig {
|
||||
hint: "fast".to_string(),
|
||||
provider: "lmstudio".to_string(),
|
||||
model: "qwen2.5-coder".to_string(),
|
||||
max_tokens: None,
|
||||
api_key: None,
|
||||
transport: None,
|
||||
}];
|
||||
|
||||
let err = match create_routed_provider_with_options(
|
||||
"provider-that-does-not-exist",
|
||||
None,
|
||||
None,
|
||||
&reliability,
|
||||
&routes,
|
||||
"hint:reasoning",
|
||||
&ProviderRuntimeOptions::default(),
|
||||
) {
|
||||
Ok(_) => panic!("missing default hint route should fail initialization"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("default_model uses hint 'reasoning'"));
|
||||
}
|
||||
|
||||
// --- parse_provider_profile ---
|
||||
|
||||
#[test]
|
||||
|
||||
@ -649,6 +649,7 @@ impl Provider for OllamaProvider {
|
||||
tool_calls,
|
||||
usage,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
|
||||
@ -667,6 +668,7 @@ impl Provider for OllamaProvider {
|
||||
tool_calls: vec![],
|
||||
usage,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -714,6 +716,7 @@ impl Provider for OllamaProvider {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,6 +301,7 @@ impl OpenAiProvider {
|
||||
tool_calls,
|
||||
usage: None,
|
||||
reasoning_content,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
@ -397,6 +398,10 @@ impl Provider for OpenAiProvider {
|
||||
return Err(super::api_error("OpenAI", response).await);
|
||||
}
|
||||
|
||||
// Extract quota metadata from response headers before consuming body
|
||||
let quota_extractor = super::quota_adapter::UniversalQuotaExtractor::new();
|
||||
let quota_metadata = quota_extractor.extract("openai", response.headers(), None);
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
@ -410,6 +415,7 @@ impl Provider for OpenAiProvider {
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?;
|
||||
let mut result = Self::parse_native_response(message);
|
||||
result.usage = usage;
|
||||
result.quota_metadata = quota_metadata;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@ -461,6 +467,10 @@ impl Provider for OpenAiProvider {
|
||||
return Err(super::api_error("OpenAI", response).await);
|
||||
}
|
||||
|
||||
// Extract quota metadata from response headers before consuming body
|
||||
let quota_extractor = super::quota_adapter::UniversalQuotaExtractor::new();
|
||||
let quota_metadata = quota_extractor.extract("openai", response.headers(), None);
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let usage = native_response.usage.map(|u| TokenUsage {
|
||||
input_tokens: u.prompt_tokens,
|
||||
@ -474,6 +484,7 @@ impl Provider for OpenAiProvider {
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?;
|
||||
let mut result = Self::parse_native_response(message);
|
||||
result.usage = usage;
|
||||
result.quota_metadata = quota_metadata;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@ -302,6 +302,7 @@ impl OpenRouterProvider {
|
||||
tool_calls,
|
||||
usage: None,
|
||||
reasoning_content,
|
||||
quota_metadata: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -103,6 +103,9 @@ pub fn build_quota_summary(
|
||||
rate_limit_remaining,
|
||||
rate_limit_reset_at,
|
||||
rate_limit_total,
|
||||
account_id: profile.account_id.clone(),
|
||||
token_expires_at: profile.token_set.as_ref().and_then(|ts| ts.expires_at),
|
||||
plan_type: profile.metadata.get("plan_type").cloned(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -424,6 +427,9 @@ fn add_qwen_oauth_static_quota(
|
||||
rate_limit_remaining: None, // Unknown without local tracking
|
||||
rate_limit_reset_at: None, // Daily reset (exact time unknown)
|
||||
rate_limit_total: Some(1000), // OAuth free tier limit
|
||||
account_id: None,
|
||||
token_expires_at: None,
|
||||
plan_type: Some("free".to_string()),
|
||||
}],
|
||||
});
|
||||
|
||||
|
||||
145
src/providers/quota_types.rs
Normal file
145
src/providers/quota_types.rs
Normal file
@ -0,0 +1,145 @@
|
||||
//! Shared types for quota and rate limit tracking.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Quota metadata extracted from provider responses (HTTP headers or errors).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuotaMetadata {
|
||||
/// Number of requests remaining in current quota window
|
||||
pub rate_limit_remaining: Option<u64>,
|
||||
/// Timestamp when the rate limit resets (UTC)
|
||||
pub rate_limit_reset_at: Option<DateTime<Utc>>,
|
||||
/// Number of seconds to wait before retry (from Retry-After header)
|
||||
pub retry_after_seconds: Option<u64>,
|
||||
/// Maximum requests allowed in quota window (if available)
|
||||
pub rate_limit_total: Option<u64>,
|
||||
}
|
||||
|
||||
/// Status of a provider's quota and circuit breaker state.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QuotaStatus {
|
||||
/// Provider is healthy and available
|
||||
Ok,
|
||||
/// Provider is rate-limited but circuit is still closed
|
||||
RateLimited,
|
||||
/// Circuit breaker is open (too many failures)
|
||||
CircuitOpen,
|
||||
/// OAuth profile quota exhausted
|
||||
QuotaExhausted,
|
||||
}
|
||||
|
||||
/// Per-provider quota information combining health state and OAuth profile metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderQuotaInfo {
|
||||
pub provider: String,
|
||||
pub status: QuotaStatus,
|
||||
pub failure_count: u32,
|
||||
pub last_error: Option<String>,
|
||||
pub retry_after_seconds: Option<u64>,
|
||||
pub circuit_resets_at: Option<DateTime<Utc>>,
|
||||
pub profiles: Vec<ProfileQuotaInfo>,
|
||||
}
|
||||
|
||||
/// Per-OAuth-profile quota information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileQuotaInfo {
|
||||
pub profile_name: String,
|
||||
pub status: QuotaStatus,
|
||||
pub rate_limit_remaining: Option<u64>,
|
||||
pub rate_limit_reset_at: Option<DateTime<Utc>>,
|
||||
pub rate_limit_total: Option<u64>,
|
||||
/// Account identifier (email, workspace ID, etc.)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub account_id: Option<String>,
|
||||
/// When the OAuth token / subscription expires
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token_expires_at: Option<DateTime<Utc>>,
|
||||
/// Plan type (free, pro, enterprise) if known
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Summary of all providers' quota status.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QuotaSummary {
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub providers: Vec<ProviderQuotaInfo>,
|
||||
}
|
||||
|
||||
impl QuotaSummary {
|
||||
/// Get available (healthy) providers
|
||||
pub fn available_providers(&self) -> Vec<&str> {
|
||||
self.providers
|
||||
.iter()
|
||||
.filter(|p| p.status == QuotaStatus::Ok)
|
||||
.map(|p| p.provider.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get rate-limited providers
|
||||
pub fn rate_limited_providers(&self) -> Vec<&str> {
|
||||
self.providers
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.status == QuotaStatus::RateLimited || p.status == QuotaStatus::QuotaExhausted
|
||||
})
|
||||
.map(|p| p.provider.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get circuit-open providers
|
||||
pub fn circuit_open_providers(&self) -> Vec<&str> {
|
||||
self.providers
|
||||
.iter()
|
||||
.filter(|p| p.status == QuotaStatus::CircuitOpen)
|
||||
.map(|p| p.provider.as_str())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider usage metrics (tracked per request).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderUsageMetrics {
|
||||
pub provider: String,
|
||||
pub requests_today: u64,
|
||||
pub requests_session: u64,
|
||||
pub tokens_input_today: u64,
|
||||
pub tokens_output_today: u64,
|
||||
pub tokens_input_session: u64,
|
||||
pub tokens_output_session: u64,
|
||||
pub cost_usd_today: f64,
|
||||
pub cost_usd_session: f64,
|
||||
pub daily_request_limit: u64,
|
||||
pub daily_token_limit: u64,
|
||||
pub last_reset_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl Default for ProviderUsageMetrics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: String::new(),
|
||||
requests_today: 0,
|
||||
requests_session: 0,
|
||||
tokens_input_today: 0,
|
||||
tokens_output_today: 0,
|
||||
tokens_input_session: 0,
|
||||
tokens_output_session: 0,
|
||||
cost_usd_today: 0.0,
|
||||
cost_usd_session: 0.0,
|
||||
daily_request_limit: 0,
|
||||
daily_token_limit: 0,
|
||||
last_reset_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderUsageMetrics {
|
||||
pub fn new(provider: &str) -> Self {
|
||||
Self {
|
||||
provider: provider.to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1807,6 +1807,7 @@ mod tests {
|
||||
tool_calls: self.tool_calls.clone(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -2000,6 +2001,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,12 +48,17 @@ impl RouterProvider {
|
||||
let resolved_routes: HashMap<String, (usize, String)> = routes
|
||||
.into_iter()
|
||||
.filter_map(|(hint, route)| {
|
||||
let normalized_hint = hint.trim();
|
||||
if normalized_hint.is_empty() {
|
||||
tracing::warn!("Route hint is empty after trimming, skipping");
|
||||
return None;
|
||||
}
|
||||
let index = name_to_index.get(route.provider_name.as_str()).copied();
|
||||
match index {
|
||||
Some(i) => Some((hint, (i, route.model))),
|
||||
Some(i) => Some((normalized_hint.to_string(), (i, route.model))),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
hint = hint,
|
||||
hint = normalized_hint,
|
||||
provider = route.provider_name,
|
||||
"Route references unknown provider, skipping"
|
||||
);
|
||||
@ -63,10 +68,17 @@ impl RouterProvider {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let default_index = default_model
|
||||
.strip_prefix("hint:")
|
||||
.map(str::trim)
|
||||
.filter(|hint| !hint.is_empty())
|
||||
.and_then(|hint| resolved_routes.get(hint).map(|(idx, _)| *idx))
|
||||
.unwrap_or(0);
|
||||
|
||||
Self {
|
||||
routes: resolved_routes,
|
||||
providers,
|
||||
default_index: 0,
|
||||
default_index,
|
||||
default_model,
|
||||
vision_override: None,
|
||||
}
|
||||
@ -85,11 +97,12 @@ impl RouterProvider {
|
||||
/// Resolve a model parameter to a (provider_index, actual_model) pair.
|
||||
fn resolve(&self, model: &str) -> (usize, String) {
|
||||
if let Some(hint) = model.strip_prefix("hint:") {
|
||||
if let Some((idx, resolved_model)) = self.routes.get(hint) {
|
||||
let normalized_hint = hint.trim();
|
||||
if let Some((idx, resolved_model)) = self.routes.get(normalized_hint) {
|
||||
return (*idx, resolved_model.clone());
|
||||
}
|
||||
tracing::warn!(
|
||||
hint = hint,
|
||||
hint = normalized_hint,
|
||||
"Unknown route hint, falling back to default provider"
|
||||
);
|
||||
}
|
||||
@ -375,6 +388,30 @@ mod tests {
|
||||
assert_eq!(model, "claude-opus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_trims_whitespace_in_hint_reference() {
|
||||
let (router, _) = make_router(
|
||||
vec![("fast", "ok"), ("smart", "ok")],
|
||||
vec![("reasoning", "smart", "claude-opus")],
|
||||
);
|
||||
|
||||
let (idx, model) = router.resolve("hint: reasoning ");
|
||||
assert_eq!(idx, 1);
|
||||
assert_eq!(model, "claude-opus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_matches_routes_with_whitespace_hint_config() {
|
||||
let (router, _) = make_router(
|
||||
vec![("fast", "ok"), ("smart", "ok")],
|
||||
vec![(" reasoning ", "smart", "claude-opus")],
|
||||
);
|
||||
|
||||
let (idx, model) = router.resolve("hint:reasoning");
|
||||
assert_eq!(idx, 1);
|
||||
assert_eq!(model, "claude-opus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_routes_with_unknown_provider() {
|
||||
let (router, _) = make_router(
|
||||
|
||||
@ -79,6 +79,9 @@ pub struct ChatResponse {
|
||||
/// sent back in subsequent API requests — some providers reject tool-call
|
||||
/// history that omits this field.
|
||||
pub reasoning_content: Option<String>,
|
||||
/// Quota metadata extracted from response headers (if available).
|
||||
/// Populated by providers that support quota tracking.
|
||||
pub quota_metadata: Option<super::quota_types::QuotaMetadata>,
|
||||
}
|
||||
|
||||
impl ChatResponse {
|
||||
@ -372,6 +375,7 @@ pub trait Provider: Send + Sync {
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -384,6 +388,7 @@ pub trait Provider: Send + Sync {
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -419,6 +424,7 @@ pub trait Provider: Send + Sync {
|
||||
tool_calls: Vec::new(),
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
@ -548,6 +554,7 @@ mod tests {
|
||||
tool_calls: vec![],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
assert!(!empty.has_tool_calls());
|
||||
assert_eq!(empty.text_or_empty(), "");
|
||||
@ -561,6 +568,7 @@ mod tests {
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
assert!(with_tools.has_tool_calls());
|
||||
assert_eq!(with_tools.text_or_empty(), "Let me check");
|
||||
@ -583,6 +591,7 @@ mod tests {
|
||||
output_tokens: Some(50),
|
||||
}),
|
||||
reasoning_content: None,
|
||||
quota_metadata: None,
|
||||
};
|
||||
assert_eq!(resp.usage.as_ref().unwrap().input_tokens, Some(100));
|
||||
assert_eq!(resp.usage.as_ref().unwrap().output_tokens, Some(50));
|
||||
|
||||
56
src/security/file_link_guard.rs
Normal file
56
src/security/file_link_guard.rs
Normal file
@ -0,0 +1,56 @@
|
||||
use std::fs::Metadata;
|
||||
|
||||
/// Returns true when a file has multiple hard links.
|
||||
///
|
||||
/// Multiple links can allow path-based workspace guards to be bypassed by
|
||||
/// linking a workspace path to external sensitive content.
|
||||
pub fn has_multiple_hard_links(metadata: &Metadata) -> bool {
|
||||
link_count(metadata) > 1
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn link_count(metadata: &Metadata) -> u64 {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
metadata.nlink()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn link_count(metadata: &Metadata) -> u64 {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
u64::from(metadata.number_of_links())
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn link_count(_metadata: &Metadata) -> u64 {
|
||||
1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn single_link_file_is_not_flagged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("single.txt");
|
||||
std::fs::write(&file, "hello").unwrap();
|
||||
let meta = std::fs::metadata(&file).unwrap();
|
||||
assert!(!has_multiple_hard_links(&meta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_link_file_is_flagged_when_supported() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let original = dir.path().join("original.txt");
|
||||
let linked = dir.path().join("linked.txt");
|
||||
std::fs::write(&original, "hello").unwrap();
|
||||
|
||||
if std::fs::hard_link(&original, &linked).is_err() {
|
||||
// Some filesystems may disable hard links; treat as unsupported.
|
||||
return;
|
||||
}
|
||||
|
||||
let meta = std::fs::metadata(&original).unwrap();
|
||||
assert!(has_multiple_hard_links(&meta));
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,7 @@ use std::sync::OnceLock;
|
||||
/// Generic rules (password=, secret=, token=) only fire when `sensitivity` exceeds
|
||||
/// this threshold, reducing false positives on technical content.
|
||||
const GENERIC_SECRET_SENSITIVITY_THRESHOLD: f64 = 0.5;
|
||||
const ENTROPY_TOKEN_MIN_LEN: usize = 20;
|
||||
const ENTROPY_TOKEN_MIN_LEN: usize = 24;
|
||||
const HIGH_ENTROPY_BASELINE: f64 = 4.2;
|
||||
|
||||
/// Result of leak detection.
|
||||
@ -307,6 +307,12 @@ impl LeakDetector {
|
||||
patterns: &mut Vec<String>,
|
||||
redacted: &mut String,
|
||||
) {
|
||||
// Keep low-sensitivity mode conservative: structural patterns still
|
||||
// run at any sensitivity, but entropy heuristics should not trigger.
|
||||
if self.sensitivity <= GENERIC_SECRET_SENSITIVITY_THRESHOLD {
|
||||
return;
|
||||
}
|
||||
|
||||
let threshold = (HIGH_ENTROPY_BASELINE + (self.sensitivity - 0.5) * 0.6).clamp(3.9, 4.8);
|
||||
let mut flagged = false;
|
||||
|
||||
@ -455,7 +461,9 @@ MIIEowIBAAKCAQEA0ZPr5JeyVDonXsKhfq...
|
||||
#[test]
|
||||
fn low_sensitivity_skips_generic() {
|
||||
let detector = LeakDetector::with_sensitivity(0.3);
|
||||
let content = "secret=mygenericvalue123456";
|
||||
// Use low entropy so this test only exercises the generic rule gate and
|
||||
// does not trip the independent high-entropy detector.
|
||||
let content = "secret=aaaaaaaaaaaaaaaa";
|
||||
let result = detector.scan(content);
|
||||
// Low sensitivity should not flag generic secrets
|
||||
assert!(matches!(result, LeakResult::Clean));
|
||||
|
||||
@ -23,6 +23,7 @@ pub mod audit;
|
||||
pub mod bubblewrap;
|
||||
pub mod detect;
|
||||
pub mod docker;
|
||||
pub mod file_link_guard;
|
||||
|
||||
// Prompt injection defense (contributed from RustyClaw, MIT licensed)
|
||||
pub mod domain_matcher;
|
||||
@ -39,6 +40,7 @@ pub mod policy;
|
||||
pub mod prompt_guard;
|
||||
pub mod roles;
|
||||
pub mod secrets;
|
||||
pub mod sensitive_paths;
|
||||
pub mod syscall_anomaly;
|
||||
pub mod traits;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user