Merge remote-tracking branch 'origin/main' into pr2093-mainmerge

This commit is contained in:
argenis de la rosa 2026-02-28 17:33:17 -05:00
commit f7de9cda3a
145 changed files with 15627 additions and 1355 deletions

View 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"
}

View File

@ -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"

View File

@ -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"
}
]
}

View File

@ -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`

View File

@ -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
View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}`);
};

View File

@ -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) {

View File

@ -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

View File

@ -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
View File

@ -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"

View File

@ -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

View File

@ -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 \

View File

@ -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.

View File

@ -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| {

View File

@ -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)

View File

@ -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+.

View File

@ -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]

View File

@ -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`

View File

@ -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]]`

View File

@ -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

View File

@ -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)

View 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)

View File

@ -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`

View File

@ -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

View File

@ -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

View 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

View File

@ -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`)

View 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

View File

@ -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

View 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.

View File

@ -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

View 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
View 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

View File

@ -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"

View File

@ -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(),
});

View File

@ -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);

View File

@ -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"));

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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"
);
}
}

View File

@ -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",

View File

@ -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};

View File

@ -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
View 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"));
}
}

View File

@ -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;

View File

@ -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]";

View File

@ -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
View 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"));
}
}

View File

@ -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(

File diff suppressed because it is too large Load Diff

523
src/channels/napcat.rs Normal file
View 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]"));
}
}

View File

@ -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

View File

@ -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"
);
}
}

View File

@ -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

View File

@ -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}"),
}

View File

@ -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,
});

View File

@ -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

View File

@ -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

View File

@ -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, &current_ch.api_token);
restore_optional_secret(&mut incoming_ch.signing_secret, &current_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, &current_ch.access_token);
restore_optional_secret(&mut incoming_ch.webhook_secret, &current_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, &current_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, &current_ch.access_token);
}
if let (Some(incoming_ch), Some(current_ch)) = (
incoming.channels_config.qq.as_mut(),
current.channels_config.qq.as_ref(),

View File

@ -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());

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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,
});

View File

@ -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,

View File

@ -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"));
}
}

View File

@ -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)
}

View File

@ -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)?;

View File

@ -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();

View File

@ -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
View 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());
}
}

View File

@ -882,6 +882,7 @@ impl BedrockProvider {
tool_calls,
usage,
reasoning_content: None,
quota_metadata: None,
}
}

View File

@ -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,
});
}

View File

@ -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
View 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}"
);
}
}

View File

@ -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
View 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);
}
}

View File

@ -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]

View File

@ -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,
})
}
}

View File

@ -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)
}

View File

@ -302,6 +302,7 @@ impl OpenRouterProvider {
tool_calls,
usage: None,
reasoning_content,
quota_metadata: None,
}
}

View File

@ -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()),
}],
});

View 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()
}
}
}

View File

@ -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,
})
}
}

View File

@ -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(

View File

@ -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));

View 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));
}
}

View File

@ -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));

View File

@ -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