diff --git a/.cargo/config.toml b/.cargo/config.toml
index 50b1cb0f7..a4f3978f3 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -1,3 +1,12 @@
+# macOS targets — pin minimum OS version so binaries run on supported releases.
+# Intel (x86_64): target macOS 10.15 Catalina and later.
+# Apple Silicon (aarch64): target macOS 11.0 Big Sur and later (no Catalina hardware exists).
+[target.x86_64-apple-darwin]
+rustflags = ["-C", "link-arg=-mmacosx-version-min=10.15"]
+
+[target.aarch64-apple-darwin]
+rustflags = ["-C", "link-arg=-mmacosx-version-min=11.0"]
+
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "link-arg=-static"]
@@ -15,3 +24,10 @@ linker = "clang"
[target.aarch64-linux-android]
linker = "clang"
+
+# Windows targets — increase stack size for large JsonSchema derives
+[target.x86_64-pc-windows-msvc]
+rustflags = ["-C", "link-args=/STACK:8388608"]
+
+[target.aarch64-pc-windows-msvc]
+rustflags = ["-C", "link-args=/STACK:8388608"]
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index b3d28233c..ce7812d95 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -4,3 +4,9 @@ self-hosted-runner:
- X64
- racknerd
- aws-india
+ - light
+ - cpu40
+ - codeql
+ - codeql-general
+ - blacksmith-2vcpu-ubuntu-2404
+ - hetzner
diff --git a/.github/workflows/ci-canary-gate.yml b/.github/workflows/ci-canary-gate.yml
index de99b707e..3b1995367 100644
--- a/.github/workflows/ci-canary-gate.yml
+++ b/.github/workflows/ci-canary-gate.yml
@@ -89,7 +89,7 @@ env:
jobs:
canary-plan:
name: Canary Plan
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
outputs:
mode: ${{ steps.inputs.outputs.mode }}
@@ -122,7 +122,8 @@ jobs:
trigger_rollback_on_abort="true"
rollback_branch="dev"
rollback_target_ref=""
- fail_on_violation="true"
+ # Scheduled audits may not have live canary telemetry; report violations without failing by default.
+ fail_on_violation="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
mode="${{ github.event.inputs.mode || 'dry-run' }}"
@@ -237,7 +238,7 @@ jobs:
name: Canary Execute
needs: [canary-plan]
if: github.event_name == 'workflow_dispatch' && needs.canary-plan.outputs.mode == 'execute' && needs.canary-plan.outputs.ready_to_execute == 'true'
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 10
permissions:
contents: write
diff --git a/.github/workflows/ci-change-audit.yml b/.github/workflows/ci-change-audit.yml
index 9f09538e5..8fbf33e4a 100644
--- a/.github/workflows/ci-change-audit.yml
+++ b/.github/workflows/ci-change-audit.yml
@@ -50,7 +50,7 @@ env:
jobs:
audit:
name: CI Change Audit
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 15
steps:
- name: Checkout
@@ -59,9 +59,10 @@ jobs:
fetch-depth: 0
- name: Setup Python
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- with:
- python-version: "3.12"
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 --version
- name: Resolve base/head commits
id: refs
diff --git a/.github/workflows/ci-post-release-validation.yml b/.github/workflows/ci-post-release-validation.yml
new file mode 100644
index 000000000..f9a737744
--- /dev/null
+++ b/.github/workflows/ci-post-release-validation.yml
@@ -0,0 +1,88 @@
+---
+name: Post-Release Validation
+
+on:
+ release:
+ types: ["published"]
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ name: Validate Published Release
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Download and verify release assets
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+
+ echo "Validating release: ${RELEASE_TAG}"
+
+ # 1. Check release exists and is not draft
+ release_json="$(gh api \
+ "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}")"
+ is_draft="$(echo "$release_json" \
+ | python3 -c "import sys,json; print(json.load(sys.stdin)['draft'])")"
+ if [ "$is_draft" = "True" ]; then
+ echo "::warning::Release ${RELEASE_TAG} is still in draft."
+ fi
+
+ # 2. Check expected assets against artifact contract
+ asset_count="$(echo "$release_json" \
+ | python3 -c "import sys,json; print(len(json.load(sys.stdin)['assets']))")"
+ contract=".github/release/release-artifact-contract.json"
+ expected_count="$(python3 -c "
+ import json
+ c = json.load(open('$contract'))
+ total = sum(len(c[k]) for k in c if k != 'schema_version')
+ print(total)
+ ")"
+ echo "Release has ${asset_count} assets (contract expects ${expected_count})"
+ if [ "$asset_count" -lt "$expected_count" ]; then
+ echo "::error::Expected >=${expected_count} release assets (from ${contract}), found ${asset_count}"
+ exit 1
+ fi
+
+ # 3. Download checksum file and one archive
+ gh release download "${RELEASE_TAG}" \
+ --pattern "SHA256SUMS" \
+ --dir /tmp/release-check
+ gh release download "${RELEASE_TAG}" \
+ --pattern "zeroclaw-x86_64-unknown-linux-gnu.tar.gz" \
+ --dir /tmp/release-check
+
+ # 4. Verify checksum
+ cd /tmp/release-check
+ if sha256sum --check --ignore-missing SHA256SUMS; then
+ echo "SHA256 checksum verification: passed"
+ else
+ echo "::error::SHA256 checksum verification failed"
+ exit 1
+ fi
+
+ # 5. Extract binary
+ tar xzf zeroclaw-x86_64-unknown-linux-gnu.tar.gz
+
+ - name: Smoke-test release binary
+ shell: bash
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ set -euo pipefail
+ cd /tmp/release-check
+ if ./zeroclaw --version | grep -Fq "${RELEASE_TAG#v}"; then
+ echo "Binary version check: passed (${RELEASE_TAG})"
+ else
+ actual="$(./zeroclaw --version)"
+ echo "::error::Binary --version mismatch: ${actual}"
+ exit 1
+ fi
+ echo "Post-release validation: all checks passed"
diff --git a/.github/workflows/ci-provider-connectivity.yml b/.github/workflows/ci-provider-connectivity.yml
index 3008f86b2..701f923b3 100644
--- a/.github/workflows/ci-provider-connectivity.yml
+++ b/.github/workflows/ci-provider-connectivity.yml
@@ -39,7 +39,7 @@ env:
jobs:
probe:
name: Provider Connectivity Probe
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
steps:
- name: Checkout
diff --git a/.github/workflows/ci-queue-hygiene.yml b/.github/workflows/ci-queue-hygiene.yml
index ada0baf02..c30c81f58 100644
--- a/.github/workflows/ci-queue-hygiene.yml
+++ b/.github/workflows/ci-queue-hygiene.yml
@@ -2,13 +2,13 @@ name: CI Queue Hygiene
on:
schedule:
- - cron: "*/15 * * * *"
+ - cron: "*/5 * * * *"
workflow_dispatch:
inputs:
apply:
description: "Cancel selected queued runs (false = dry-run report only)"
required: true
- default: true
+ default: false
type: boolean
status:
description: "Queued-run status scope"
@@ -42,7 +42,7 @@ env:
jobs:
hygiene:
name: Queue Hygiene
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 15
steps:
- name: Checkout
@@ -51,6 +51,8 @@ jobs:
- name: Run queue hygiene policy
id: hygiene
shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
mkdir -p artifacts
@@ -61,18 +63,24 @@ jobs:
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' }}"
+ apply_mode="${{ github.event.inputs.apply || 'false' }}"
fi
cmd=(python3 scripts/ci/queue_hygiene.py
--repo "${{ github.repository }}"
--status "${status_scope}"
--max-cancel "${max_cancel}"
+ --dedupe-workflow "CI Run"
+ --dedupe-workflow "Test E2E"
+ --dedupe-workflow "Docs Deploy"
--dedupe-workflow "PR Intake Checks"
--dedupe-workflow "PR Labeler"
--dedupe-workflow "PR Auto Responder"
--dedupe-workflow "Workflow Sanity"
--dedupe-workflow "PR Label Policy Check"
+ --priority-branch-prefix "release/"
+ --dedupe-include-non-pr
+ --non-pr-key branch
--output-json artifacts/queue-hygiene-report.json
--verbose)
diff --git a/.github/workflows/ci-reproducible-build.yml b/.github/workflows/ci-reproducible-build.yml
index e9b019b98..358fea637 100644
--- a/.github/workflows/ci-reproducible-build.yml
+++ b/.github/workflows/ci-reproducible-build.yml
@@ -8,7 +8,11 @@ on:
- "Cargo.lock"
- "src/**"
- "crates/**"
+ - "scripts/ci/ensure_c_toolchain.sh"
+ - "scripts/ci/ensure_cargo_component.sh"
+ - "scripts/ci/ensure_cc.sh"
- "scripts/ci/reproducible_build_check.sh"
+ - "scripts/ci/self_heal_rust_toolchain.sh"
- ".github/workflows/ci-reproducible-build.yml"
pull_request:
branches: [dev, main]
@@ -17,7 +21,11 @@ on:
- "Cargo.lock"
- "src/**"
- "crates/**"
+ - "scripts/ci/ensure_c_toolchain.sh"
+ - "scripts/ci/ensure_cargo_component.sh"
+ - "scripts/ci/ensure_cc.sh"
- "scripts/ci/reproducible_build_check.sh"
+ - "scripts/ci/self_heal_rust_toolchain.sh"
- ".github/workflows/ci-reproducible-build.yml"
schedule:
- cron: "45 5 * * 1" # Weekly Monday 05:45 UTC
@@ -50,17 +58,37 @@ env:
jobs:
reproducibility:
name: Reproducible Build Probe
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 45
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 75
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+
- name: Setup Rust
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
+
- name: Run reproducible build check
shell: bash
run: |
diff --git a/.github/workflows/ci-rollback.yml b/.github/workflows/ci-rollback.yml
index b9f2f28e0..a96721440 100644
--- a/.github/workflows/ci-rollback.yml
+++ b/.github/workflows/ci-rollback.yml
@@ -48,7 +48,7 @@ on:
- cron: "15 7 * * 1" # Weekly Monday 07:15 UTC
concurrency:
- group: ci-rollback-${{ github.event.inputs.branch || 'dev' }}
+ group: ci-rollback-${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.branch || 'dev') || github.ref_name }}
cancel-in-progress: false
permissions:
@@ -64,7 +64,7 @@ env:
jobs:
rollback-plan:
name: Rollback Guard Plan
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
outputs:
branch: ${{ steps.plan.outputs.branch }}
@@ -77,7 +77,7 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- ref: ${{ github.event.inputs.branch || 'dev' }}
+ ref: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.branch || 'dev') || github.ref_name }}
- name: Build rollback plan
id: plan
@@ -86,11 +86,12 @@ jobs:
set -euo pipefail
mkdir -p artifacts
- branch_input="dev"
+ branch_input="${GITHUB_REF_NAME}"
mode_input="dry-run"
target_ref_input=""
allow_non_ancestor="false"
- fail_on_violation="true"
+ # Scheduled audits can surface historical rollback violations; report without blocking by default.
+ fail_on_violation="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
branch_input="${{ github.event.inputs.branch || 'dev' }}"
@@ -188,7 +189,7 @@ jobs:
name: Rollback Execute Actions
needs: [rollback-plan]
if: github.event_name == 'workflow_dispatch' && needs.rollback-plan.outputs.mode == 'execute' && needs.rollback-plan.outputs.ready_to_execute == 'true'
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 15
permissions:
contents: write
diff --git a/.github/workflows/ci-run.yml b/.github/workflows/ci-run.yml
index 196b15cc6..dbbc6b740 100644
--- a/.github/workflows/ci-run.yml
+++ b/.github/workflows/ci-run.yml
@@ -9,7 +9,7 @@ on:
branches: [dev, main]
concurrency:
- group: ci-${{ github.event.pull_request.number || github.sha }}
+ group: ci-run-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.sha }}
cancel-in-progress: true
permissions:
@@ -24,7 +24,7 @@ env:
jobs:
changes:
name: Detect Change Scope
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
outputs:
docs_only: ${{ steps.scope.outputs.docs_only }}
docs_changed: ${{ steps.scope.outputs.docs_changed }}
@@ -50,19 +50,35 @@ jobs:
name: Lint Gate (Format + Clippy + Strict Delta)
needs: [changes]
if: needs.changes.outputs.rust_changed == 'true'
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 40
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 75
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
components: rustfmt, clippy
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+ - name: Ensure cargo component
+ shell: bash
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
with:
prefix-key: ci-run-check
+ cache-bin: false
- name: Run rust quality gate
run: ./scripts/ci/rust_quality_gate.sh
- name: Run strict lint delta gate
@@ -70,20 +86,82 @@ jobs:
BASE_SHA: ${{ needs.changes.outputs.base_sha }}
run: ./scripts/ci/rust_strict_delta_gate.sh
- test:
- name: Test
+ workspace-check:
+ name: Workspace Check
needs: [changes]
if: needs.changes.outputs.rust_changed == 'true'
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 60
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 45
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
+ with:
+ prefix-key: ci-run-workspace-check
+ cache-bin: false
+ - name: Check workspace
+ run: cargo check --workspace --locked
+
+ package-check:
+ name: Package Check (${{ matrix.package }})
+ needs: [changes]
+ if: needs.changes.outputs.rust_changed == 'true'
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ package: [zeroclaw-types, zeroclaw-core]
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+ - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
+ with:
+ toolchain: 1.92.0
+ - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
+ with:
+ prefix-key: ci-run-package-check
+ cache-bin: false
+ - name: Check package
+ run: cargo check -p ${{ matrix.package }} --locked
+
+ test:
+ name: Test
+ needs: [changes]
+ if: needs.changes.outputs.rust_changed == 'true'
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 120
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+ - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
+ with:
+ toolchain: 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+ - name: Ensure cargo component
+ shell: bash
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
with:
prefix-key: ci-run-check
+ cache-bin: false
- name: Run tests with flake detection
shell: bash
env:
@@ -92,6 +170,20 @@ jobs:
set -euo pipefail
mkdir -p artifacts
+ toolchain_bin=""
+ if [ -n "${CARGO:-}" ]; then
+ toolchain_bin="$(dirname "${CARGO}")"
+ elif [ -n "${RUSTC:-}" ]; then
+ toolchain_bin="$(dirname "${RUSTC}")"
+ fi
+
+ if [ -n "${toolchain_bin}" ] && [ -d "${toolchain_bin}" ]; then
+ case ":$PATH:" in
+ *":${toolchain_bin}:"*) ;;
+ *) export PATH="${toolchain_bin}:$PATH" ;;
+ esac
+ fi
+
if cargo test --locked --verbose; then
echo '{"flake_suspected":false,"status":"success"}' > artifacts/flake-probe.json
exit 0
@@ -137,28 +229,51 @@ jobs:
name: Build (Smoke)
needs: [changes]
if: needs.changes.outputs.rust_changed == 'true'
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 35
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 90
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+ - name: Ensure cargo component
+ shell: bash
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
with:
prefix-key: ci-run-build
cache-targets: true
+ cache-bin: false
- name: Build binary (smoke check)
- run: cargo build --profile release-fast --locked --verbose
+ env:
+ CARGO_BUILD_JOBS: 2
+ CI_SMOKE_BUILD_ATTEMPTS: 3
+ run: bash scripts/ci/smoke_build_retry.sh
- name: Check binary size
+ env:
+ BINARY_SIZE_HARD_LIMIT_MB: 28
+ BINARY_SIZE_ADVISORY_MB: 20
+ BINARY_SIZE_TARGET_MB: 5
run: bash scripts/ci/check_binary_size.sh target/release-fast/zeroclaw
docs-only:
name: Docs-Only Fast Path
needs: [changes]
if: needs.changes.outputs.docs_only == 'true'
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
steps:
- name: Skip heavy jobs for docs-only change
run: echo "Docs-only change detected. Rust lint/test/build skipped."
@@ -167,7 +282,7 @@ jobs:
name: Non-Rust Fast Path
needs: [changes]
if: needs.changes.outputs.docs_only != 'true' && needs.changes.outputs.rust_changed != 'true'
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
steps:
- name: Skip Rust jobs for non-Rust change scope
run: echo "No Rust-impacting files changed. Rust lint/test/build skipped."
@@ -176,12 +291,16 @@ jobs:
name: Docs Quality
needs: [changes]
if: needs.changes.outputs.docs_changed == 'true'
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
+ - name: Setup Node.js for markdown lint
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
- name: Markdown lint (changed lines only)
env:
@@ -231,7 +350,7 @@ jobs:
name: Lint Feedback
if: github.event_name == 'pull_request'
needs: [changes, lint, docs-quality]
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
permissions:
contents: read
pull-requests: write
@@ -257,7 +376,7 @@ jobs:
name: License File Owner Guard
needs: [changes]
if: github.event_name == 'pull_request'
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
permissions:
contents: read
pull-requests: read
@@ -274,8 +393,8 @@ jobs:
ci-required:
name: CI Required Gate
if: always()
- needs: [changes, lint, test, build, docs-only, non-rust, docs-quality, lint-feedback, license-file-owner-guard]
- runs-on: ubuntu-22.04
+ needs: [changes, lint, workspace-check, package-check, test, build, docs-only, non-rust, docs-quality, lint-feedback, license-file-owner-guard]
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
steps:
- name: Enforce required status
shell: bash
@@ -322,10 +441,14 @@ jobs:
# --- Rust change path ---
lint_result="${{ needs.lint.result }}"
+ workspace_check_result="${{ needs.workspace-check.result }}"
+ package_check_result="${{ needs.package-check.result }}"
test_result="${{ needs.test.result }}"
build_result="${{ needs.build.result }}"
echo "lint=${lint_result}"
+ echo "workspace-check=${workspace_check_result}"
+ echo "package-check=${package_check_result}"
echo "test=${test_result}"
echo "build=${build_result}"
echo "docs=${docs_result}"
@@ -333,8 +456,8 @@ jobs:
check_pr_governance
- if [ "$lint_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
- echo "Required CI jobs did not pass: lint=${lint_result} test=${test_result} build=${build_result}"
+ if [ "$lint_result" != "success" ] || [ "$workspace_check_result" != "success" ] || [ "$package_check_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then
+ echo "Required CI jobs did not pass: lint=${lint_result} workspace-check=${workspace_check_result} package-check=${package_check_result} test=${test_result} build=${build_result}"
exit 1
fi
diff --git a/.github/workflows/ci-supply-chain-provenance.yml b/.github/workflows/ci-supply-chain-provenance.yml
index 1ec83351d..3460dfd1c 100644
--- a/.github/workflows/ci-supply-chain-provenance.yml
+++ b/.github/workflows/ci-supply-chain-provenance.yml
@@ -8,6 +8,7 @@ on:
- "Cargo.lock"
- "src/**"
- "crates/**"
+ - "scripts/ci/ensure_cc.sh"
- "scripts/ci/generate_provenance.py"
- ".github/workflows/ci-supply-chain-provenance.yml"
workflow_dispatch:
@@ -31,7 +32,7 @@ env:
jobs:
provenance:
name: Build + Provenance Bundle
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 60
steps:
- name: Checkout
@@ -42,12 +43,51 @@ jobs:
with:
toolchain: 1.92.0
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
+
+ - name: Activate toolchain binaries on PATH
+ shell: bash
+ run: |
+ set -euo pipefail
+ toolchain_bin="$(dirname "$(rustup which --toolchain 1.92.0 cargo)")"
+ echo "$toolchain_bin" >> "$GITHUB_PATH"
+
+ - name: Resolve host target
+ id: rust-meta
+ shell: bash
+ run: |
+ set -euo pipefail
+ host_target="$(rustup run 1.92.0 rustc -vV | sed -n 's/^host: //p')"
+ if [ -z "${host_target}" ]; then
+ echo "::error::Unable to resolve Rust host target."
+ exit 1
+ fi
+ echo "host_target=${host_target}" >> "$GITHUB_OUTPUT"
+
+ - name: Runner preflight (compiler + disk)
+ shell: bash
+ run: |
+ set -euo pipefail
+ ./scripts/ci/ensure_cc.sh
+ echo "Runner: ${RUNNER_NAME:-unknown} (${RUNNER_OS:-unknown}/${RUNNER_ARCH:-unknown})"
+ free_kb="$(df -Pk . | awk 'NR==2 {print $4}')"
+ min_kb=$((10 * 1024 * 1024))
+ if [ "${free_kb}" -lt "${min_kb}" ]; then
+ echo "::error::Insufficient disk space on runner (<10 GiB free)."
+ df -h .
+ exit 1
+ fi
+
- name: Build release-fast artifact
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts
- host_target="$(rustc -vV | sed -n 's/^host: //p')"
+ host_target="${{ steps.rust-meta.outputs.host_target }}"
cargo build --profile release-fast --locked --target "$host_target"
cp "target/${host_target}/release-fast/zeroclaw" "artifacts/zeroclaw-${host_target}"
sha256sum "artifacts/zeroclaw-${host_target}" > "artifacts/zeroclaw-${host_target}.sha256"
@@ -56,7 +96,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- host_target="$(rustc -vV | sed -n 's/^host: //p')"
+ host_target="${{ steps.rust-meta.outputs.host_target }}"
python3 scripts/ci/generate_provenance.py \
--artifact "artifacts/zeroclaw-${host_target}" \
--subject-name "zeroclaw-${host_target}" \
@@ -69,7 +109,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- host_target="$(rustc -vV | sed -n 's/^host: //p')"
+ host_target="${{ steps.rust-meta.outputs.host_target }}"
statement="artifacts/provenance-${host_target}.intoto.json"
cosign sign-blob --yes \
--bundle="${statement}.sigstore.json" \
@@ -81,7 +121,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- host_target="$(rustc -vV | sed -n 's/^host: //p')"
+ host_target="${{ steps.rust-meta.outputs.host_target }}"
python3 scripts/ci/emit_audit_event.py \
--event-type supply_chain_provenance \
--input-json "artifacts/provenance-${host_target}.intoto.json" \
@@ -100,7 +140,7 @@ jobs:
shell: bash
run: |
set -euo pipefail
- host_target="$(rustc -vV | sed -n 's/^host: //p')"
+ host_target="${{ steps.rust-meta.outputs.host_target }}"
{
echo "### Supply Chain Provenance"
echo "- Target: \`${host_target}\`"
diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index eb0fb5eb3..03e865549 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -2,7 +2,7 @@ name: Deploy Web to GitHub Pages
on:
push:
- branches: [main, dev]
+ branches: [main]
paths:
- 'web/**'
workflow_dispatch:
@@ -18,7 +18,7 @@ concurrency:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -48,7 +48,7 @@ jobs:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
needs: build
steps:
- name: Deploy to GitHub Pages
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 6ac5c220a..470df4a6c 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -41,7 +41,7 @@ on:
default: ""
concurrency:
- group: docs-deploy-${{ github.event.pull_request.number || github.sha }}
+ group: docs-deploy-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.sha }}
cancel-in-progress: true
permissions:
@@ -56,7 +56,7 @@ env:
jobs:
docs-quality:
name: Docs Quality Gate
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
outputs:
docs_files: ${{ steps.scope.outputs.docs_files }}
@@ -73,6 +73,11 @@ jobs:
with:
fetch-depth: 0
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
+
- name: Resolve docs diff scope
id: scope
shell: bash
@@ -160,6 +165,11 @@ jobs:
if-no-files-found: ignore
retention-days: ${{ steps.deploy_guard.outputs.docs_guard_artifact_retention_days || 21 }}
+ - name: Setup Node.js for markdown lint
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "22"
+
- name: Markdown quality gate
env:
BASE_SHA: ${{ steps.scope.outputs.base_sha }}
@@ -203,7 +213,7 @@ jobs:
name: Docs Preview Artifact
needs: [docs-quality]
if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.deploy_target == 'preview')
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -237,7 +247,7 @@ jobs:
name: Deploy Docs to GitHub Pages
needs: [docs-quality]
if: needs.docs-quality.outputs.deploy_target == 'production' && needs.docs-quality.outputs.ready_to_deploy == 'true'
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
permissions:
contents: read
diff --git a/.github/workflows/feature-matrix.yml b/.github/workflows/feature-matrix.yml
index b3221c8f6..576c49981 100644
--- a/.github/workflows/feature-matrix.yml
+++ b/.github/workflows/feature-matrix.yml
@@ -51,7 +51,7 @@ env:
jobs:
resolve-profile:
name: Resolve Matrix Profile
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
outputs:
profile: ${{ steps.resolve.outputs.profile }}
lane_job_prefix: ${{ steps.resolve.outputs.lane_job_prefix }}
@@ -127,7 +127,7 @@ jobs:
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'ci:full') ||
contains(github.event.pull_request.labels.*.name, 'ci:feature-matrix')
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: ${{ fromJSON(needs.resolve-profile.outputs.lane_timeout_minutes) }}
strategy:
fail-fast: false
@@ -155,6 +155,11 @@ jobs:
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
with:
@@ -278,7 +283,7 @@ jobs:
name: ${{ needs.resolve-profile.outputs.summary_job_name }}
needs: [resolve-profile, feature-check]
if: always()
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
diff --git a/.github/workflows/nightly-all-features.yml b/.github/workflows/nightly-all-features.yml
index 12156fb38..209003727 100644
--- a/.github/workflows/nightly-all-features.yml
+++ b/.github/workflows/nightly-all-features.yml
@@ -27,7 +27,7 @@ env:
jobs:
nightly-lanes:
name: Nightly Lane (${{ matrix.name }})
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 70
strategy:
fail-fast: false
@@ -53,6 +53,11 @@ jobs:
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
with:
@@ -137,7 +142,7 @@ jobs:
name: Nightly Summary & Routing
needs: [nightly-lanes]
if: always()
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
diff --git a/.github/workflows/pages-deploy.yml b/.github/workflows/pages-deploy.yml
index eeff0b9d8..34fca0b01 100644
--- a/.github/workflows/pages-deploy.yml
+++ b/.github/workflows/pages-deploy.yml
@@ -22,7 +22,7 @@ concurrency:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
steps:
- name: Checkout
@@ -53,7 +53,7 @@ jobs:
deploy:
needs: build
- runs-on: ubuntu-latest
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.github/workflows/pr-auto-response.yml b/.github/workflows/pr-auto-response.yml
index 133785990..9865d40b7 100644
--- a/.github/workflows/pr-auto-response.yml
+++ b/.github/workflows/pr-auto-response.yml
@@ -8,7 +8,9 @@ on:
types: [opened, labeled, unlabeled]
concurrency:
- group: pr-auto-response-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}
+ # Keep cancellation within the same lifecycle action to avoid `labeled`
+ # events canceling an in-flight `opened` run for the same issue/PR.
+ group: pr-auto-response-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }}-${{ github.event.action || 'unknown' }}
cancel-in-progress: true
permissions: {}
@@ -21,12 +23,11 @@ env:
jobs:
contributor-tier-issues:
+ # Only run for opened/reopened events to avoid duplicate runs with labeled-routes job
if: >-
(github.event_name == 'issues' &&
- (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: ubuntu-22.04
+ (github.event.action == 'opened' || github.event.action == 'reopened'))
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
permissions:
contents: read
issues: write
@@ -45,7 +46,7 @@ jobs:
await script({ github, context, core });
first-interaction:
if: github.event.action == 'opened'
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
permissions:
issues: write
pull-requests: write
@@ -76,7 +77,7 @@ jobs:
labeled-routes:
if: github.event.action == 'labeled'
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
permissions:
contents: read
issues: write
diff --git a/.github/workflows/pr-check-stale.yml b/.github/workflows/pr-check-stale.yml
index bb166e1e1..b6d322322 100644
--- a/.github/workflows/pr-check-stale.yml
+++ b/.github/workflows/pr-check-stale.yml
@@ -17,7 +17,7 @@ jobs:
permissions:
issues: write
pull-requests: write
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 10
steps:
- name: Mark stale issues and pull requests
diff --git a/.github/workflows/pr-check-status.yml b/.github/workflows/pr-check-status.yml
index bdd1ab04a..32eb1634a 100644
--- a/.github/workflows/pr-check-status.yml
+++ b/.github/workflows/pr-check-status.yml
@@ -18,7 +18,7 @@ env:
jobs:
nudge-stale-prs:
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 10
permissions:
contents: read
diff --git a/.github/workflows/pr-intake-checks.yml b/.github/workflows/pr-intake-checks.yml
index 66a8bbe66..37c9ee0a9 100644
--- a/.github/workflows/pr-intake-checks.yml
+++ b/.github/workflows/pr-intake-checks.yml
@@ -23,7 +23,7 @@ env:
jobs:
intake:
name: Intake Checks
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 10
steps:
- name: Checkout repository
diff --git a/.github/workflows/pr-label-policy-check.yml b/.github/workflows/pr-label-policy-check.yml
index 5da237e17..12bc60773 100644
--- a/.github/workflows/pr-label-policy-check.yml
+++ b/.github/workflows/pr-label-policy-check.yml
@@ -28,7 +28,7 @@ env:
jobs:
contributor-tier-consistency:
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 10
steps:
- name: Checkout
diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml
index acc8364cc..61e72ab7b 100644
--- a/.github/workflows/pr-labeler.yml
+++ b/.github/workflows/pr-labeler.yml
@@ -32,7 +32,7 @@ env:
jobs:
label:
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
diff --git a/.github/workflows/pub-docker-img.yml b/.github/workflows/pub-docker-img.yml
index 47f296f98..1a6520e29 100644
--- a/.github/workflows/pub-docker-img.yml
+++ b/.github/workflows/pub-docker-img.yml
@@ -17,6 +17,11 @@ on:
- "scripts/ci/ghcr_publish_contract_guard.py"
- "scripts/ci/ghcr_vulnerability_gate.py"
workflow_dispatch:
+ inputs:
+ release_tag:
+ description: "Existing release tag to publish (e.g. v0.2.0). Leave empty for smoke-only run."
+ required: false
+ type: string
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}
@@ -32,8 +37,8 @@ env:
jobs:
pr-smoke:
name: PR Docker Smoke
- if: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)
- runs-on: [self-hosted, aws-india]
+ if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || (github.event_name == 'workflow_dispatch' && inputs.release_tag == '')
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 25
permissions:
contents: read
@@ -41,6 +46,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Resolve Docker API version
+ shell: bash
+ run: |
+ set -euo pipefail
+ server_api="$(docker version --format '{{.Server.APIVersion}}')"
+ min_api="$(docker version --format '{{.Server.MinAPIVersion}}' 2>/dev/null || true)"
+ if [[ -z "${server_api}" || "${server_api}" == "" ]]; then
+ echo "::error::Unable to detect Docker server API version."
+ docker version || true
+ exit 1
+ fi
+ echo "DOCKER_API_VERSION=${server_api}" >> "$GITHUB_ENV"
+ echo "Using Docker API version ${server_api} (server min: ${min_api:-unknown})"
+
- name: Setup Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
@@ -72,9 +91,9 @@ jobs:
publish:
name: Build and Push Docker Image
- if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'zeroclaw-labs/zeroclaw'
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 45
+ if: github.repository == 'zeroclaw-labs/zeroclaw' && ((github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != ''))
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 90
permissions:
contents: read
packages: write
@@ -82,6 +101,22 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.release_tag) || github.ref }}
+
+ - name: Resolve Docker API version
+ shell: bash
+ run: |
+ set -euo pipefail
+ server_api="$(docker version --format '{{.Server.APIVersion}}')"
+ min_api="$(docker version --format '{{.Server.MinAPIVersion}}' 2>/dev/null || true)"
+ if [[ -z "${server_api}" || "${server_api}" == "" ]]; then
+ echo "::error::Unable to detect Docker server API version."
+ docker version || true
+ exit 1
+ fi
+ echo "DOCKER_API_VERSION=${server_api}" >> "$GITHUB_ENV"
+ echo "Using Docker API version ${server_api} (server min: ${min_api:-unknown})"
- name: Setup Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
@@ -99,22 +134,42 @@ jobs:
run: |
set -euo pipefail
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
- SHA_SUFFIX="sha-${GITHUB_SHA::12}"
+ if [[ "${GITHUB_EVENT_NAME}" == "push" ]]; then
+ if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
+ echo "::error::Docker publish is restricted to v* tag pushes."
+ exit 1
+ fi
+ RELEASE_TAG="${GITHUB_REF#refs/tags/}"
+ elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
+ RELEASE_TAG="${{ inputs.release_tag }}"
+ if [[ -z "${RELEASE_TAG}" ]]; then
+ echo "::error::workflow_dispatch publish requires inputs.release_tag"
+ exit 1
+ fi
+ if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
+ echo "::error::release_tag must be vX.Y.Z or vX.Y.Z-suffix (received: ${RELEASE_TAG})"
+ exit 1
+ fi
+ if ! git rev-parse --verify "refs/tags/${RELEASE_TAG}" >/dev/null 2>&1; then
+ echo "::error::release tag not found in checkout: ${RELEASE_TAG}"
+ exit 1
+ fi
+ else
+ echo "::error::Unsupported event for publish: ${GITHUB_EVENT_NAME}"
+ exit 1
+ fi
+ RELEASE_SHA="$(git rev-parse HEAD)"
+ SHA_SUFFIX="sha-${RELEASE_SHA::12}"
SHA_TAG="${IMAGE}:${SHA_SUFFIX}"
LATEST_SUFFIX="latest"
LATEST_TAG="${IMAGE}:${LATEST_SUFFIX}"
- if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then
- echo "::error::Docker publish is restricted to v* tag pushes."
- exit 1
- fi
-
- RELEASE_TAG="${GITHUB_REF#refs/tags/}"
VERSION_TAG="${IMAGE}:${RELEASE_TAG}"
TAGS="${VERSION_TAG},${SHA_TAG},${LATEST_TAG}"
{
echo "tags=${TAGS}"
echo "release_tag=${RELEASE_TAG}"
+ echo "release_sha=${RELEASE_SHA}"
echo "sha_tag=${SHA_SUFFIX}"
echo "latest_tag=${LATEST_SUFFIX}"
} >> "$GITHUB_OUTPUT"
@@ -124,6 +179,8 @@ jobs:
with:
context: .
push: true
+ build-args: |
+ ZEROCLAW_CARGO_ALL_FEATURES=true
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
@@ -173,7 +230,7 @@ jobs:
python3 scripts/ci/ghcr_publish_contract_guard.py \
--repository "${GITHUB_REPOSITORY,,}" \
--release-tag "${{ steps.meta.outputs.release_tag }}" \
- --sha "${GITHUB_SHA}" \
+ --sha "${{ steps.meta.outputs.release_sha }}" \
--policy-file .github/release/ghcr-tag-policy.json \
--output-json artifacts/ghcr-publish-contract.json \
--output-md artifacts/ghcr-publish-contract.md \
@@ -328,11 +385,25 @@ jobs:
if-no-files-found: ignore
retention-days: 21
- - name: Upload Trivy SARIF
+ - name: Detect Trivy SARIF report
+ id: trivy-sarif
if: always()
+ shell: bash
+ run: |
+ set -euo pipefail
+ sarif_path="artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif"
+ if [ -f "${sarif_path}" ]; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::Trivy SARIF report not found at ${sarif_path}; skipping SARIF upload."
+ fi
+
+ - name: Upload Trivy SARIF
+ if: always() && steps.trivy-sarif.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
with:
- sarif_file: artifacts/trivy-${{ github.ref_name }}.sarif
+ sarif_file: artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif
category: ghcr-trivy
- name: Upload Trivy report artifacts
@@ -341,9 +412,9 @@ jobs:
with:
name: ghcr-trivy-report
path: |
- artifacts/trivy-${{ github.ref_name }}.sarif
- artifacts/trivy-${{ github.ref_name }}.txt
- artifacts/trivy-${{ github.ref_name }}.json
+ artifacts/trivy-${{ steps.meta.outputs.release_tag }}.sarif
+ artifacts/trivy-${{ steps.meta.outputs.release_tag }}.txt
+ artifacts/trivy-${{ steps.meta.outputs.release_tag }}.json
artifacts/trivy-sha-*.txt
artifacts/trivy-sha-*.json
artifacts/trivy-latest.txt
diff --git a/.github/workflows/pub-prerelease.yml b/.github/workflows/pub-prerelease.yml
index e68671aaa..e56ab170f 100644
--- a/.github/workflows/pub-prerelease.yml
+++ b/.github/workflows/pub-prerelease.yml
@@ -43,7 +43,7 @@ env:
jobs:
prerelease-guard:
name: Pre-release Guard
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
outputs:
release_tag: ${{ steps.vars.outputs.release_tag }}
@@ -177,7 +177,7 @@ jobs:
needs: [prerelease-guard]
# Keep GNU Linux prerelease artifacts on Ubuntu 22.04 so runtime GLIBC
# symbols remain compatible with Debian 12 / Ubuntu 22.04 hosts.
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 45
steps:
- name: Checkout tag
@@ -239,7 +239,7 @@ jobs:
name: Publish GitHub Pre-release
needs: [prerelease-guard, build-prerelease]
if: needs.prerelease-guard.outputs.ready_to_publish == 'true'
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 15
steps:
- name: Download prerelease artifacts
diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml
index fe92edc8a..e02598bfc 100644
--- a/.github/workflows/pub-release.yml
+++ b/.github/workflows/pub-release.yml
@@ -47,7 +47,8 @@ env:
jobs:
prepare:
name: Prepare Release Context
- runs-on: [self-hosted, aws-india]
+ if: github.event_name != 'push' || !contains(github.ref_name, '-')
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
outputs:
release_ref: ${{ steps.vars.outputs.release_ref }}
release_tag: ${{ steps.vars.outputs.release_tag }}
@@ -106,7 +107,35 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
- name: Checkout
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Install gh CLI
+ shell: bash
+ run: |
+ set -euo pipefail
+ if command -v gh &>/dev/null; then
+ echo "gh already available: $(gh --version | head -1)"
+ exit 0
+ fi
+ echo "Installing gh CLI..."
+ curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
+ | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
+ | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
+ for i in {1..60}; do
+ if sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1 \
+ || sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
+ || sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; then
+ echo "apt/dpkg locked; waiting ($i/60)..."
+ sleep 5
+ else
+ break
+ fi
+ done
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 update -qq
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y gh
+ env:
+ GH_TOKEN: ${{ github.token }}
- name: Validate release trigger and authorization guard
shell: bash
@@ -127,6 +156,8 @@ jobs:
--output-json artifacts/release-trigger-guard.json \
--output-md artifacts/release-trigger-guard.md \
--fail-on-violation
+ env:
+ GH_TOKEN: ${{ github.token }}
- name: Emit release trigger audit event
if: always()
@@ -164,20 +195,24 @@ jobs:
needs: [prepare]
runs-on: ${{ matrix.os }}
timeout-minutes: 40
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.target }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}-${{ matrix.target }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/target
strategy:
fail-fast: false
matrix:
include:
# Keep GNU Linux release artifacts on Ubuntu 22.04 to preserve
# a broadly compatible GLIBC baseline for user distributions.
- - os: ubuntu-22.04
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
archive_ext: tar.gz
cross_compiler: ""
linker_env: ""
linker: ""
- - os: self-hosted
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: x86_64-unknown-linux-musl
artifact: zeroclaw
archive_ext: tar.gz
@@ -185,14 +220,14 @@ jobs:
linker_env: ""
linker: ""
use_cross: true
- - os: ubuntu-22.04
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
archive_ext: tar.gz
cross_compiler: gcc-aarch64-linux-gnu
linker_env: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER
linker: aarch64-linux-gnu-gcc
- - os: self-hosted
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: aarch64-unknown-linux-musl
artifact: zeroclaw
archive_ext: tar.gz
@@ -200,14 +235,14 @@ jobs:
linker_env: ""
linker: ""
use_cross: true
- - os: ubuntu-22.04
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: armv7-unknown-linux-gnueabihf
artifact: zeroclaw
archive_ext: tar.gz
cross_compiler: gcc-arm-linux-gnueabihf
linker_env: CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER
linker: arm-linux-gnueabihf-gcc
- - os: self-hosted
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: armv7-linux-androideabi
artifact: zeroclaw
archive_ext: tar.gz
@@ -216,7 +251,7 @@ jobs:
linker: ""
android_ndk: true
android_api: 21
- - os: self-hosted
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: aarch64-linux-android
artifact: zeroclaw
archive_ext: tar.gz
@@ -225,7 +260,7 @@ jobs:
linker: ""
android_ndk: true
android_api: 21
- - os: self-hosted
+ - os: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
target: x86_64-unknown-freebsd
artifact: zeroclaw
archive_ext: tar.gz
@@ -260,6 +295,10 @@ jobs:
with:
ref: ${{ needs.prepare.outputs.release_ref }}
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
@@ -270,14 +309,38 @@ jobs:
- name: Install cross for cross-built targets
if: matrix.use_cross
+ shell: bash
run: |
- cargo install cross --git https://github.com/cross-rs/cross
+ set -euo pipefail
+ echo "${CARGO_HOME:-$HOME/.cargo}/bin" >> "$GITHUB_PATH"
+ cargo install cross --locked --version 0.2.5
+ command -v cross
+ cross --version
- name: Install cross-compilation toolchain (Linux)
if: runner.os == 'Linux' && matrix.cross_compiler != ''
run: |
- sudo apt-get update -qq
- sudo apt-get install -y "${{ matrix.cross_compiler }}"
+ set -euo pipefail
+ for i in {1..60}; do
+ if sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1 \
+ || sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
+ || sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; then
+ echo "apt/dpkg locked; waiting ($i/60)..."
+ sleep 5
+ else
+ break
+ fi
+ done
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 update -qq
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y "${{ matrix.cross_compiler }}"
+ # Install matching libc dev headers for cross targets
+ # (required by ring/aws-lc-sys C compilation)
+ case "${{ matrix.target }}" in
+ armv7-unknown-linux-gnueabihf)
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y libc6-dev-armhf-cross ;;
+ aarch64-unknown-linux-gnu)
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y libc6-dev-arm64-cross ;;
+ esac
- name: Setup Android NDK
if: matrix.android_ndk
@@ -290,8 +353,18 @@ jobs:
NDK_ROOT="${RUNNER_TEMP}/android-ndk"
NDK_HOME="${NDK_ROOT}/android-ndk-${NDK_VERSION}"
- sudo apt-get update -qq
- sudo apt-get install -y unzip
+ for i in {1..60}; do
+ if sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1 \
+ || sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
+ || sudo fuser /var/lib/dpkg/lock >/dev/null 2>&1; then
+ echo "apt/dpkg locked; waiting ($i/60)..."
+ sleep 5
+ else
+ break
+ fi
+ done
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 update -qq
+ sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3 install -y unzip
mkdir -p "${NDK_ROOT}"
curl -fsSL "${NDK_URL}" -o "${RUNNER_TEMP}/${NDK_ZIP}"
@@ -362,6 +435,10 @@ jobs:
- name: Check binary size (Unix)
if: runner.os != 'Windows'
+ env:
+ BINARY_SIZE_HARD_LIMIT_MB: 28
+ BINARY_SIZE_ADVISORY_MB: 20
+ BINARY_SIZE_TARGET_MB: 5
run: bash scripts/ci/check_binary_size.sh "target/${{ matrix.target }}/release-fast/${{ matrix.artifact }}" "${{ matrix.target }}"
- name: Package (Unix)
@@ -386,7 +463,7 @@ jobs:
verify-artifacts:
name: Verify Artifact Set
needs: [prepare, build-release]
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -447,7 +524,7 @@ jobs:
name: Publish Release
if: needs.prepare.outputs.publish_release == 'true'
needs: [prepare, verify-artifacts]
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 45
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml
new file mode 100644
index 000000000..42bd3e20f
--- /dev/null
+++ b/.github/workflows/release-build.yml
@@ -0,0 +1,102 @@
+name: Production Release Build
+
+on:
+ push:
+ branches: ["main"]
+ tags: ["v*"]
+ workflow_dispatch:
+
+concurrency:
+ group: production-release-build-${{ github.ref || github.run_id }}
+ cancel-in-progress: false
+
+permissions:
+ contents: read
+
+env:
+ GIT_CONFIG_COUNT: "1"
+ GIT_CONFIG_KEY_0: core.hooksPath
+ GIT_CONFIG_VALUE_0: /dev/null
+ CARGO_TERM_COLOR: always
+
+jobs:
+ build-and-test:
+ name: Build and Test (Linux x86_64)
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 120
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+
+ - name: Setup Rust
+ uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
+ with:
+ toolchain: 1.92.0
+ components: rustfmt, clippy
+
+ - name: Ensure C toolchain for Rust builds
+ shell: bash
+ run: ./scripts/ci/ensure_cc.sh
+
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
+
+ - name: Ensure rustfmt and clippy components
+ shell: bash
+ run: rustup component add rustfmt clippy --toolchain 1.92.0
+
+ - name: Activate toolchain binaries on PATH
+ shell: bash
+ run: |
+ set -euo pipefail
+ toolchain_bin="$(dirname "$(rustup which --toolchain 1.92.0 cargo)")"
+ echo "$toolchain_bin" >> "$GITHUB_PATH"
+
+ - name: Cache Cargo registry and target
+ uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
+ with:
+ prefix-key: production-release-build
+ shared-key: ${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
+ cache-targets: true
+ cache-bin: false
+
+ - name: Rust quality gates
+ shell: bash
+ run: |
+ set -euo pipefail
+ ./scripts/ci/rust_quality_gate.sh
+ cargo test --locked --lib --bins --verbose
+
+ - name: Build production binary (canonical)
+ shell: bash
+ run: cargo build --release --locked
+
+ - name: Prepare artifact bundle
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p artifacts
+ cp target/release/zeroclaw artifacts/zeroclaw
+ sha256sum artifacts/zeroclaw > artifacts/zeroclaw.sha256
+
+ - name: Upload production artifact
+ uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+ with:
+ name: zeroclaw-linux-amd64
+ path: |
+ artifacts/zeroclaw
+ artifacts/zeroclaw.sha256
+ if-no-files-found: error
+ retention-days: 21
diff --git a/.github/workflows/scripts/pr_intake_checks.js b/.github/workflows/scripts/pr_intake_checks.js
index 0a07239d1..9b6371af1 100644
--- a/.github/workflows/scripts/pr_intake_checks.js
+++ b/.github/workflows/scripts/pr_intake_checks.js
@@ -88,8 +88,8 @@ module.exports = async ({ github, context, core }) => {
blockingFindings.push(`Dangerous patch markers found (${dangerousProblems.length})`);
}
if (linearKeys.length === 0) {
- blockingFindings.push(
- "Missing Linear issue key reference (`RMN-`, `CDV-`, or `COM-`) in PR title/body.",
+ advisoryFindings.push(
+ "Missing Linear issue key reference (`RMN-`, `CDV-`, or `COM-`) in PR title/body (recommended for traceability, non-blocking).",
);
}
@@ -156,7 +156,7 @@ module.exports = async ({ github, context, core }) => {
"",
"Action items:",
"1. Complete required PR template sections/fields.",
- "2. Link this PR to exactly one active Linear issue key (`RMN-xxx`/`CDV-xxx`/`COM-xxx`).",
+ "2. (Recommended) Link this PR to one active Linear issue key (`RMN-xxx`/`CDV-xxx`/`COM-xxx`) for traceability.",
"3. Remove tabs, trailing whitespace, and merge conflict markers from added lines.",
"4. Re-run local checks before pushing:",
" - `./scripts/ci/rust_quality_gate.sh`",
diff --git a/.github/workflows/sec-audit.yml b/.github/workflows/sec-audit.yml
index 51e763222..3ba0d050f 100644
--- a/.github/workflows/sec-audit.yml
+++ b/.github/workflows/sec-audit.yml
@@ -15,6 +15,9 @@ on:
- ".github/security/unsafe-audit-governance.json"
- "scripts/ci/install_gitleaks.sh"
- "scripts/ci/install_syft.sh"
+ - "scripts/ci/ensure_c_toolchain.sh"
+ - "scripts/ci/ensure_cargo_component.sh"
+ - "scripts/ci/self_heal_rust_toolchain.sh"
- "scripts/ci/deny_policy_guard.py"
- "scripts/ci/secrets_governance_guard.py"
- "scripts/ci/unsafe_debt_audit.py"
@@ -22,29 +25,12 @@ on:
- "scripts/ci/config/unsafe_debt_policy.toml"
- "scripts/ci/emit_audit_event.py"
- "scripts/ci/security_regression_tests.sh"
+ - "scripts/ci/ensure_cc.sh"
- ".github/workflows/sec-audit.yml"
pull_request:
branches: [dev, main]
- paths:
- - "Cargo.toml"
- - "Cargo.lock"
- - "src/**"
- - "crates/**"
- - "deny.toml"
- - ".gitleaks.toml"
- - ".github/security/gitleaks-allowlist-governance.json"
- - ".github/security/deny-ignore-governance.json"
- - ".github/security/unsafe-audit-governance.json"
- - "scripts/ci/install_gitleaks.sh"
- - "scripts/ci/install_syft.sh"
- - "scripts/ci/deny_policy_guard.py"
- - "scripts/ci/secrets_governance_guard.py"
- - "scripts/ci/unsafe_debt_audit.py"
- - "scripts/ci/unsafe_policy_guard.py"
- - "scripts/ci/config/unsafe_debt_policy.toml"
- - "scripts/ci/emit_audit_event.py"
- - "scripts/ci/security_regression_tests.sh"
- - ".github/workflows/sec-audit.yml"
+ # Do not gate pull_request by paths: main branch protection requires
+ # "Security Required Gate" to always report a status on PRs.
merge_group:
branches: [dev, main]
schedule:
@@ -86,14 +72,34 @@ env:
jobs:
audit:
name: Security Audit
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 20
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 45
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0
with:
@@ -101,11 +107,28 @@ jobs:
deny:
name: License & Supply Chain
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+
+ - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
+ with:
+ toolchain: 1.92.0
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
+
- name: Enforce deny policy hygiene
shell: bash
run: |
@@ -118,9 +141,46 @@ jobs:
--output-md artifacts/deny-policy-guard.md \
--fail-on-violation
- - uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2
- with:
- command: check advisories licenses sources
+ - name: Install cargo-deny
+ shell: bash
+ run: |
+ set -euo pipefail
+ version="0.19.0"
+ arch="$(uname -m)"
+ case "${arch}" in
+ x86_64|amd64)
+ target="x86_64-unknown-linux-musl"
+ expected_sha256="0e8c2aa59128612c90d9e09c02204e912f29a5b8d9a64671b94608cbe09e064f"
+ ;;
+ aarch64|arm64)
+ target="aarch64-unknown-linux-musl"
+ expected_sha256="2b3567a60b7491c159d1cef8b7d8479d1ad2a31e29ef49462634ad4552fcc77d"
+ ;;
+ *)
+ echo "Unsupported runner architecture for cargo-deny: ${arch}" >&2
+ exit 1
+ ;;
+ esac
+ install_dir="${RUNNER_TEMP}/cargo-deny-${version}"
+ archive="${RUNNER_TEMP}/cargo-deny-${version}-${target}.tar.gz"
+ mkdir -p "${install_dir}"
+ curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \
+ --output "${archive}" \
+ "https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-${target}.tar.gz"
+ actual_sha256="$(sha256sum "${archive}" | awk '{print $1}')"
+ if [ "${actual_sha256}" != "${expected_sha256}" ]; then
+ echo "Checksum mismatch for cargo-deny ${version} (${target})" >&2
+ echo "Expected: ${expected_sha256}" >&2
+ echo "Actual: ${actual_sha256}" >&2
+ exit 1
+ fi
+ tar -xzf "${archive}" -C "${install_dir}" --strip-components=1
+ echo "${install_dir}" >> "${GITHUB_PATH}"
+ "${install_dir}/cargo-deny" --version
+
+ - name: Run cargo-deny checks
+ shell: bash
+ run: cargo-deny check advisories licenses sources
- name: Emit deny audit event
if: always()
@@ -156,23 +216,42 @@ jobs:
security-regressions:
name: Security Regression Tests
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 30
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+
+ - name: Self-heal Rust toolchain cache
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
with:
prefix-key: sec-audit-security-regressions
+ cache-bin: false
- name: Run security regression suite
shell: bash
run: ./scripts/ci/security_regression_tests.sh
secrets:
name: Secrets Governance (Gitleaks)
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -367,7 +446,7 @@ jobs:
sbom:
name: SBOM Snapshot
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -432,11 +511,17 @@ jobs:
unsafe-debt:
name: Unsafe Debt Audit
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ - name: Setup Python 3.11
+ shell: bash
+ run: |
+ set -euo pipefail
+ python3 --version
+
- name: Enforce unsafe policy governance
shell: bash
run: |
@@ -571,7 +656,7 @@ jobs:
name: Security Required Gate
if: always() && (github.event_name == 'pull_request' || github.event_name == 'push' || github.event_name == 'merge_group')
needs: [audit, deny, security-regressions, secrets, sbom, unsafe-debt]
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
steps:
- name: Enforce security gate
shell: bash
diff --git a/.github/workflows/sec-codeql.yml b/.github/workflows/sec-codeql.yml
index 5c0c8cfcc..01bec0567 100644
--- a/.github/workflows/sec-codeql.yml
+++ b/.github/workflows/sec-codeql.yml
@@ -8,7 +8,11 @@ on:
- "Cargo.lock"
- "src/**"
- "crates/**"
+ - "scripts/ci/ensure_c_toolchain.sh"
+ - "scripts/ci/ensure_cargo_component.sh"
- ".github/codeql/**"
+ - "scripts/ci/self_heal_rust_toolchain.sh"
+ - "scripts/ci/ensure_cc.sh"
- ".github/workflows/sec-codeql.yml"
pull_request:
branches: [dev, main]
@@ -17,7 +21,11 @@ on:
- "Cargo.lock"
- "src/**"
- "crates/**"
+ - "scripts/ci/ensure_c_toolchain.sh"
+ - "scripts/ci/ensure_cargo_component.sh"
- ".github/codeql/**"
+ - "scripts/ci/self_heal_rust_toolchain.sh"
+ - "scripts/ci/ensure_cc.sh"
- ".github/workflows/sec-codeql.yml"
merge_group:
branches: [dev, main]
@@ -41,16 +49,46 @@ env:
jobs:
+ select-runner:
+ name: Select CodeQL Runner Lane
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
+ outputs:
+ labels: ${{ steps.lane.outputs.labels }}
+ lane: ${{ steps.lane.outputs.lane }}
+ steps:
+ - name: Resolve branch lane
+ id: lane
+ shell: bash
+ run: |
+ set -euo pipefail
+ branch="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}"
+ if [[ "$branch" == release/* ]]; then
+ echo 'labels=["self-hosted","Linux","X64","hetzner","codeql"]' >> "$GITHUB_OUTPUT"
+ echo 'lane=release' >> "$GITHUB_OUTPUT"
+ else
+ echo 'labels=["self-hosted","Linux","X64","hetzner","codeql","codeql-general"]' >> "$GITHUB_OUTPUT"
+ echo 'lane=general' >> "$GITHUB_OUTPUT"
+ fi
+
codeql:
name: CodeQL Analysis
- runs-on: [self-hosted, aws-india]
- timeout-minutes: 60
+ needs: [select-runner]
+ runs-on: ${{ fromJSON(needs.select-runner.outputs.labels) }}
+ timeout-minutes: 120
+ env:
+ CARGO_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/cargo
+ RUSTUP_HOME: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/rustup
+ CARGO_TARGET_DIR: ${{ github.workspace }}/.ci-rust/${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}/target
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
+ - name: Ensure C toolchain
+ shell: bash
+ run: bash ./scripts/ci/ensure_c_toolchain.sh
+
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
with:
@@ -59,10 +97,26 @@ jobs:
queries: security-and-quality
- name: Set up Rust
+ shell: bash
+ run: ./scripts/ci/self_heal_rust_toolchain.sh 1.92.0
+
+ - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
+ - name: Ensure cargo component
+ shell: bash
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
+
+ - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
+ with:
+ prefix-key: sec-codeql-build
+ cache-targets: true
+ cache-bin: false
+
- name: Build
run: cargo build --workspace --all-targets --locked
@@ -70,3 +124,14 @@ jobs:
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4
with:
category: "/language:rust"
+
+ - name: Summarize lane
+ if: always()
+ shell: bash
+ run: |
+ {
+ echo "### CodeQL Runner Lane"
+ echo "- Branch: \`${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}\`"
+ echo "- Lane: \`${{ needs.select-runner.outputs.lane }}\`"
+ echo "- Labels: \`${{ needs.select-runner.outputs.labels }}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/sec-vorpal-reviewdog.yml b/.github/workflows/sec-vorpal-reviewdog.yml
index 6b647eed4..618755038 100644
--- a/.github/workflows/sec-vorpal-reviewdog.yml
+++ b/.github/workflows/sec-vorpal-reviewdog.yml
@@ -91,7 +91,7 @@ env:
jobs:
vorpal:
name: Vorpal Reviewdog Scan
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 20
steps:
- name: Checkout
diff --git a/.github/workflows/sync-contributors.yml b/.github/workflows/sync-contributors.yml
index bdee8d4a6..a099dfc25 100644
--- a/.github/workflows/sync-contributors.yml
+++ b/.github/workflows/sync-contributors.yml
@@ -17,7 +17,7 @@ permissions:
jobs:
update-notice:
name: Update NOTICE with new contributors
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 20
steps:
- name: Checkout repository
diff --git a/.github/workflows/test-benchmarks.yml b/.github/workflows/test-benchmarks.yml
index 5fcd96db0..14588fd5a 100644
--- a/.github/workflows/test-benchmarks.yml
+++ b/.github/workflows/test-benchmarks.yml
@@ -22,7 +22,7 @@ env:
jobs:
benchmarks:
name: Criterion Benchmarks
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 30
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index ce3b00a17..595e97e1f 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -10,11 +10,12 @@ on:
- "crates/**"
- "tests/**"
- "scripts/**"
+ - "scripts/ci/ensure_cc.sh"
- ".github/workflows/test-e2e.yml"
workflow_dispatch:
concurrency:
- group: e2e-${{ github.event.pull_request.number || github.sha }}
+ group: test-e2e-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.sha }}
cancel-in-progress: true
permissions:
@@ -29,13 +30,37 @@ env:
jobs:
integration-tests:
name: Integration / E2E Tests
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 30
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable
with:
toolchain: 1.92.0
+ - name: Ensure cargo component
+ shell: bash
+ env:
+ ENSURE_CARGO_COMPONENT_STRICT: "true"
+ run: bash ./scripts/ci/ensure_cargo_component.sh 1.92.0
+ - name: Ensure C toolchain for Rust builds
+ run: ./scripts/ci/ensure_cc.sh
- uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v3
+ - name: Runner preflight (compiler + disk)
+ shell: bash
+ run: |
+ set -euo pipefail
+ echo "Runner: ${RUNNER_NAME:-unknown} (${RUNNER_OS:-unknown}/${RUNNER_ARCH:-unknown})"
+ if ! command -v cc >/dev/null 2>&1; then
+ echo "::error::Missing 'cc' compiler on runner. Install build-essential (Debian/Ubuntu) or equivalent."
+ exit 1
+ fi
+ cc --version | head -n1
+ free_kb="$(df -Pk . | awk 'NR==2 {print $4}')"
+ min_kb=$((10 * 1024 * 1024))
+ if [ "${free_kb}" -lt "${min_kb}" ]; then
+ echo "::error::Insufficient disk space on runner (<10 GiB free)."
+ df -h .
+ exit 1
+ fi
- name: Run integration / E2E tests
run: cargo test --test agent_e2e --locked --verbose
diff --git a/.github/workflows/test-fuzz.yml b/.github/workflows/test-fuzz.yml
index 8ed634a88..809672a36 100644
--- a/.github/workflows/test-fuzz.yml
+++ b/.github/workflows/test-fuzz.yml
@@ -27,7 +27,7 @@ env:
jobs:
fuzz:
name: Fuzz (${{ matrix.target }})
- runs-on: [self-hosted, aws-india]
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
timeout-minutes: 60
strategy:
fail-fast: false
diff --git a/.github/workflows/test-self-hosted.yml b/.github/workflows/test-self-hosted.yml
index 92c264397..8471d5f39 100644
--- a/.github/workflows/test-self-hosted.yml
+++ b/.github/workflows/test-self-hosted.yml
@@ -2,15 +2,89 @@ name: Test Self-Hosted Runner
on:
workflow_dispatch:
+ schedule:
+ - cron: "30 2 * * *"
+
+permissions:
+ contents: read
jobs:
- test-runner:
- runs-on: self-hosted
+ runner-health:
+ name: Runner Health / self-hosted aws-india
+ runs-on: [self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]
+ timeout-minutes: 10
steps:
- name: Check runner info
run: |
echo "Runner: $(hostname)"
echo "OS: $(uname -a)"
- echo "Docker: $(docker --version)"
+ echo "User: $(whoami)"
+ if command -v rustc >/dev/null 2>&1; then
+ echo "Rust: $(rustc --version)"
+ else
+ echo "Rust: "
+ fi
+ if command -v cargo >/dev/null 2>&1; then
+ echo "Cargo: $(cargo --version)"
+ else
+ echo "Cargo: "
+ fi
+ if command -v cc >/dev/null 2>&1; then
+ echo "CC: $(cc --version | head -n1)"
+ else
+ echo "CC: "
+ fi
+ if command -v gcc >/dev/null 2>&1; then
+ echo "GCC: $(gcc --version | head -n1)"
+ else
+ echo "GCC: "
+ fi
+ if command -v clang >/dev/null 2>&1; then
+ echo "Clang: $(clang --version | head -n1)"
+ else
+ echo "Clang: "
+ fi
+ if command -v docker >/dev/null 2>&1; then
+ echo "Docker: $(docker --version)"
+ else
+ echo "Docker: "
+ fi
+ - name: Verify compiler + disk prerequisites
+ shell: bash
+ run: |
+ set -euo pipefail
+ failed=0
+
+ if ! command -v cc >/dev/null 2>&1; then
+ echo "::error::Missing 'cc'. Install build-essential (or gcc/clang + symlink)."
+ failed=1
+ fi
+
+ free_kb="$(df -Pk . | awk 'NR==2 {print $4}')"
+ min_kb=$((10 * 1024 * 1024))
+ if [ "${free_kb}" -lt "${min_kb}" ]; then
+ echo "::error::Disk free below 10 GiB; clean runner workspace/cache."
+ df -h .
+ failed=1
+ fi
+
+ inode_used_pct="$(df -Pi . | awk 'NR==2 {gsub(/%/, "", $5); print $5}')"
+ if [ "${inode_used_pct}" -ge 95 ]; then
+ echo "::error::Inode usage >=95%; clean files to avoid ENOSPC."
+ df -i .
+ failed=1
+ fi
+
+ if [ "${failed}" -ne 0 ]; then
+ exit 1
+ fi
- name: Test Docker
- run: docker run --rm hello-world
+ shell: bash
+ run: |
+ set -euo pipefail
+ if ! command -v docker >/dev/null 2>&1; then
+ echo "::notice::Docker is not installed on this self-hosted runner. Skipping docker smoke test."
+ exit 0
+ fi
+
+ docker run --rm hello-world
diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml
index 3335f42e3..322b2389e 100644
--- a/.github/workflows/workflow-sanity.yml
+++ b/.github/workflows/workflow-sanity.yml
@@ -28,7 +28,7 @@ env:
jobs:
no-tabs:
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 10
steps:
- name: Normalize git global hooks config
@@ -67,7 +67,7 @@ jobs:
PY
actionlint:
- runs-on: ubuntu-22.04
+ runs-on: [self-hosted, Linux, X64, aws-india, light, cpu40]
timeout-minutes: 10
steps:
- name: Normalize git global hooks config
diff --git a/.gitignore b/.gitignore
index fd5c00635..108545e01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
/target
+/target_ci
+/target_review*
firmware/*/target
*.db
*.db-journal
@@ -12,7 +14,9 @@ site/node_modules/
site/.vite/
site/public/docs-content/
gh-pages/
+
.idea
+.claude
# Environment files (may contain secrets)
.env
@@ -30,10 +34,12 @@ venv/
# Secret keys and credentials
.secret_key
+otp-secret
*.key
*.pem
credentials.json
+/config.toml
.worktrees/
# Nix
-result
\ No newline at end of file
+result
diff --git a/AGENTS.md b/AGENTS.md
index 1e356bc4b..77f6ff68e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,6 +3,22 @@
This file defines the default working protocol for coding agents in this repository.
Scope: entire repository.
+## 0) Session Default Target (Mandatory)
+
+- When operator intent does not explicitly specify another repository/path, treat the active coding target as this repository (`/home/ubuntu/zeroclaw`).
+- Do not switch to or implement in other repositories unless the operator explicitly requests that scope in the current conversation.
+- Ambiguous wording (for example "这个仓库", "当前项目", "the repo") is resolved to `/home/ubuntu/zeroclaw` by default.
+- Context mentioning external repositories does not authorize cross-repo edits; explicit current-turn override is required.
+- Before any repo-affecting action, verify target lock (`pwd` + git root) to prevent accidental execution in sibling repositories.
+
+## 0.1) Clean Worktree First Gate (Mandatory)
+
+- Before handling any repository content (analysis, debugging, coding, tests, docs, CI), create a **new clean dedicated git worktree** for the active task.
+- Do not perform substantive task work in a dirty workspace.
+- Do not reuse a previously dirty worktree for a new task track.
+- If the current location is dirty, stop and bootstrap a clean worktree/branch first.
+- If worktree bootstrap fails, stop and report the blocker; do not continue in-place.
+
## 1) Project Snapshot (Read First)
ZeroClaw is a Rust-first autonomous agent runtime optimized for:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 233942347..c8821ee1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
value if the input used the legacy `enc:` format
- `SecretStore::needs_migration()` — Check if a value uses the legacy `enc:` format
- `SecretStore::is_secure_encrypted()` — Check if a value uses the secure `enc2:` format
+- `feishu_doc` tool — Feishu/Lark document operations (`read`, `write`, `append`, `create`, `list_blocks`, `get_block`, `update_block`, `delete_block`, `create_table`, `write_table_cells`, `create_table_with_values`, `upload_image`, `upload_file`)
+- Agent session persistence guidance now includes explicit backend/strategy/TTL key names for rollout notes.
- **Telegram mention_only mode** — New config option `mention_only` for Telegram channel.
When enabled, bot only responds to messages that @-mention the bot in group chats.
Direct messages always work regardless of this setting. Default: `false`.
@@ -65,4 +67,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Workspace escape prevention
- Forbidden system path protection (`/etc`, `/root`, `~/.ssh`)
-[0.1.0]: https://github.com/theonlyhennygod/zeroclaw/releases/tag/v0.1.0
+[0.1.0]: https://github.com/zeroclaw-labs/zeroclaw/releases/tag/v0.1.0
diff --git a/Cargo.lock b/Cargo.lock
index 55ed9b611..9c7540764 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -14,6 +14,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "addr2line"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
+dependencies = [
+ "gimli",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -418,6 +427,19 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "auto_encoder"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f6364e11e0270035ec392151a54f1476e6b3612ef9f4fe09d35e72a8cebcb65"
+dependencies = [
+ "chardetng",
+ "encoding_rs",
+ "percent-encoding",
+ "phf 0.11.3",
+ "phf_codegen 0.11.3",
+]
+
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -426,9 +448,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
-version = "1.16.0"
+version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
+checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -436,9 +458,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.37.1"
+version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
+checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
dependencies = [
"cc",
"cmake",
@@ -693,6 +715,9 @@ name = "bumpalo"
version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+dependencies = [
+ "allocator-api2",
+]
[[package]]
name = "bytecount"
@@ -825,12 +850,27 @@ dependencies = [
"winx",
]
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cbc"
version = "0.1.2"
@@ -905,6 +945,17 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "chardetng"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14b8f0b65b7b08ae3c8187e8d77174de20cb6777864c6b832d8ad365999cf1ea"
+dependencies = [
+ "cfg-if",
+ "encoding_rs",
+ "memchr",
+]
+
[[package]]
name = "chrono"
version = "0.4.44"
@@ -1014,7 +1065,7 @@ version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
- "heck 0.5.0",
+ "heck",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -1055,9 +1106,9 @@ dependencies = [
[[package]]
name = "cobs"
-version = "0.5.0"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ef0193218d365c251b5b9297f9911a908a8ddd2ebd3a36cc5d0ef0f63aee9e"
+checksum = "dd93fd2c1b27acd030440c9dbd9d14c1122aad622374fe05a670b67a4bc034be"
dependencies = [
"heapless",
"thiserror 2.0.18",
@@ -1069,6 +1120,20 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "compression-codecs"
version = "0.4.37"
@@ -1104,7 +1169,7 @@ dependencies = [
"encode_unicode",
"libc",
"once_cell",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"windows-sys 0.61.2",
]
@@ -1129,6 +1194,15 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+[[package]]
+name = "convert_case"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
+dependencies = [
+ "unicode-segmentation",
+]
+
[[package]]
name = "cookie"
version = "0.16.2"
@@ -1212,19 +1286,37 @@ dependencies = [
]
[[package]]
-name = "cranelift-bforest"
-version = "0.111.6"
+name = "cranelift-assembler-x64"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd5d0c30fdfa774bd91e7261f7fd56da9fce457da89a8442b3648a3af46775d5"
+checksum = "ba33ddc4e157cb1abe9da6c821e8824f99e56d057c2c22536850e0141f281d61"
+dependencies = [
+ "cranelift-assembler-x64-meta",
+]
+
+[[package]]
+name = "cranelift-assembler-x64-meta"
+version = "0.123.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b23dd6ea360e6fb28a3f3b40b7f126509668f58076a4729b2cfd656f26a0ad"
+dependencies = [
+ "cranelift-srcgen",
+]
+
+[[package]]
+name = "cranelift-bforest"
+version = "0.123.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d81afcee8fe27ee2536987df3fadcb2e161af4edb7dbe3ef36838d0ce74382"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3eb20c97ecf678a2041846f6093f54eea5dc5ea5752260885f5b8ece95dff42"
+checksum = "fb33595f1279fe7af03b28245060e9085caf98b10ed3137461a85796eb83972a"
dependencies = [
"serde",
"serde_derive",
@@ -1232,11 +1324,12 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44e40598708fd3c0a84d4c962330e5db04a30e751a957acbd310a775d05a5f4a"
+checksum = "0230a6ac0660bfe31eb244cbb43dcd4f2b3c1c4e0addc3e0348c6053ea60272e"
dependencies = [
"bumpalo",
+ "cranelift-assembler-x64",
"cranelift-bforest",
"cranelift-bitset",
"cranelift-codegen-meta",
@@ -1244,44 +1337,51 @@ dependencies = [
"cranelift-control",
"cranelift-entity",
"cranelift-isle",
- "gimli 0.29.0",
- "hashbrown 0.14.5",
+ "gimli",
+ "hashbrown 0.15.5",
"log",
+ "pulley-interpreter",
"regalloc2",
- "rustc-hash 1.1.0",
+ "rustc-hash",
+ "serde",
"smallvec",
"target-lexicon",
+ "wasmtime-internal-math",
]
[[package]]
name = "cranelift-codegen-meta"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71891d06220d3a4fd26e602138027d266a41062991e102614fbde7d9c9a645e5"
+checksum = "96d6817fdc15cb8f236fc9d8e610767d3a03327ceca4abff7a14d8e2154c405e"
dependencies = [
+ "cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
+ "cranelift-srcgen",
+ "heck",
+ "pulley-interpreter",
]
[[package]]
name = "cranelift-codegen-shared"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da72d65dba9a51ab9cbb105cf4e4aadd56b1eba68736f68d396a88a53a91cdb9"
+checksum = "0403796328e9e2e7df2b80191cdbb473fd9ea3889eb45ef5632d0fef168ea032"
[[package]]
name = "cranelift-control"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "485b4e673fd05c0e7bcef201b3ded21c0166e0d64dcdfc5fcf379c03fdce9775"
+checksum = "188f04092279a3814e0b6235c2f9c2e34028e4beb72da7bfed55cbd184702bcc"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6d9e04e7bc3f8006b9b17fe014d98c0e4b65f97c63d536969dfdb7106a1559a"
+checksum = "43f5e7391167605d505fe66a337e1a69583b3f34b63d359ffa5a430313c555e8"
dependencies = [
"cranelift-bitset",
"serde",
@@ -1290,9 +1390,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5dd834ba2b0d75dbb7fddce9d1c581c9457d4303921025af2653f42ce4c27bcf"
+checksum = "ea5440792eb2b5ba0a0976df371b9f94031bd853ae56f389de610bca7128a7cb"
dependencies = [
"cranelift-codegen",
"log",
@@ -1302,15 +1402,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7714844e9223bb002fdb9b708798cfe92ec3fb4401b21ec6cca1ac0387819489"
+checksum = "1e5c05fab6fce38d729088f3fa1060eaa1ad54eefd473588887205ed2ab2f79e"
[[package]]
name = "cranelift-native"
-version = "0.111.6"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1570411d5b06b3252b58033973499142a3c4367888bb070e6b52bfcb1d3e158f"
+checksum = "9c9a0607a028edf5ba5bba7e7cf5ca1b7f0a030e3ae84dcd401e8b9b05192280"
dependencies = [
"cranelift-codegen",
"libc",
@@ -1318,20 +1418,10 @@ dependencies = [
]
[[package]]
-name = "cranelift-wasm"
-version = "0.111.6"
+name = "cranelift-srcgen"
+version = "0.123.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f55d300101c656b79d93b1f4018838d03d9444507f8ddde1f6663b869d199a0"
-dependencies = [
- "cranelift-codegen",
- "cranelift-entity",
- "cranelift-frontend",
- "itertools 0.12.1",
- "log",
- "smallvec",
- "wasmparser 0.215.0",
- "wasmtime-types",
-]
+checksum = "cb0f2da72eb2472aaac6cfba4e785af42b1f2d82f5155f30c9c30e8cce351e17"
[[package]]
name = "crc32fast"
@@ -1423,6 +1513,49 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags 2.11.0",
+ "crossterm_winapi",
+ "mio",
+ "parking_lot",
+ "rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.11.0",
+ "crossterm_winapi",
+ "derive_more 2.1.1",
+ "document-features",
+ "mio",
+ "parking_lot",
+ "rustix 1.1.4",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "crunchy"
version = "0.2.4"
@@ -1440,6 +1573,29 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "cssparser"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf 0.13.1",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "csv"
version = "1.4.0"
@@ -1504,8 +1660,18 @@ version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
]
[[package]]
@@ -1522,13 +1688,37 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.117",
+]
+
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
- "darling_core",
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core 0.23.0",
"quote",
"syn 2.0.117",
]
@@ -1617,7 +1807,7 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58cb0719583cbe4e81fb40434ace2f0d22ccc3e39a74bb3796c22b451b4f139d"
dependencies = [
- "darling",
+ "darling 0.20.11",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -1716,6 +1906,7 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
+ "convert_case",
"proc-macro2",
"quote",
"rustc_version",
@@ -1753,16 +1944,7 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
dependencies = [
- "dirs-sys 0.5.0",
-]
-
-[[package]]
-name = "dirs"
-version = "4.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
-dependencies = [
- "dirs-sys 0.3.7",
+ "dirs-sys",
]
[[package]]
@@ -1771,18 +1953,7 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
- "dirs-sys 0.5.0",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
-dependencies = [
- "libc",
- "redox_users 0.4.6",
- "winapi",
+ "dirs-sys",
]
[[package]]
@@ -1793,7 +1964,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
- "redox_users 0.5.2",
+ "redox_users",
"windows-sys 0.61.2",
]
@@ -1837,6 +2008,21 @@ dependencies = [
"litrs",
]
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
[[package]]
name = "dunce"
version = "1.0.5"
@@ -2020,7 +2206,7 @@ dependencies = [
"regex",
"serde",
"serde_plain",
- "strum",
+ "strum 0.27.2",
"thiserror 2.0.18",
]
@@ -2035,7 +2221,7 @@ dependencies = [
"bytemuck",
"esp-idf-part",
"flate2",
- "gimli 0.32.3",
+ "gimli",
"libc",
"log",
"md-5",
@@ -2044,7 +2230,7 @@ dependencies = [
"object 0.38.1",
"serde",
"sha2",
- "strum",
+ "strum 0.27.2",
"thiserror 2.0.18",
]
@@ -2142,13 +2328,12 @@ dependencies = [
[[package]]
name = "fantoccini"
-version = "0.22.0"
+version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d0086bcd59795408c87a04f94b5a8bd62cba2856cfe656c7e6439061d95b760"
+checksum = "7737298823a6f9ca743e372e8cb03658d55354fbab843424f575706ba9563046"
dependencies = [
"base64",
"cookie 0.18.1",
- "futures-util",
"http 1.4.0",
"http-body-util",
"hyper",
@@ -2163,6 +2348,21 @@ dependencies = [
"webdriver",
]
+[[package]]
+name = "fast_html2md"
+version = "0.0.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af3a0122fee1bcf6bb9f3d73782e911cce69d95b76a5e29e930af92cd4a8e4e3"
+dependencies = [
+ "auto_encoder",
+ "futures-util",
+ "lazy_static",
+ "lol_html",
+ "percent-encoding",
+ "regex",
+ "url",
+]
+
[[package]]
name = "fastrand"
version = "2.3.0"
@@ -2236,6 +2436,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -2417,20 +2623,20 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
- "r-efi",
+ "r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
-version = "0.4.1"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
- "r-efi",
+ "r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
@@ -2446,17 +2652,6 @@ dependencies = [
"polyval",
]
-[[package]]
-name = "gimli"
-version = "0.29.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
-dependencies = [
- "fallible-iterator 0.3.0",
- "indexmap",
- "stable_deref_trait",
-]
-
[[package]]
name = "gimli"
version = "0.32.3"
@@ -2550,15 +2745,6 @@ dependencies = [
"byteorder",
]
-[[package]]
-name = "hashbrown"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
-dependencies = [
- "ahash",
-]
-
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -2567,7 +2753,6 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
- "serde",
]
[[package]]
@@ -2576,7 +2761,10 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "foldhash",
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.1.5",
+ "serde",
]
[[package]]
@@ -2584,6 +2772,11 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
[[package]]
name = "hashify"
@@ -2639,12 +2832,6 @@ dependencies = [
"stable_deref_trait",
]
-[[package]]
-name = "heck"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
-
[[package]]
name = "heck"
version = "0.5.0"
@@ -3174,6 +3361,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "inout"
version = "0.1.4"
@@ -3184,6 +3380,19 @@ dependencies = [
"generic-array",
]
+[[package]]
+name = "instability"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d"
+dependencies = [
+ "darling 0.23.0",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "instant"
version = "0.1.13"
@@ -3234,9 +3443,9 @@ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
[[package]]
name = "ipnet"
-version = "2.11.0"
+version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
@@ -3263,15 +3472,6 @@ dependencies = [
"either",
]
-[[package]]
-name = "itertools"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
-dependencies = [
- "either",
-]
-
[[package]]
name = "itertools"
version = "0.13.0"
@@ -3436,11 +3636,10 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
-version = "0.1.12"
+version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
+checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
dependencies = [
- "bitflags 2.11.0",
"libc",
]
@@ -3500,6 +3699,25 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+[[package]]
+name = "lol_html"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ff94cb6aef6ee52afd2c69331e9109906d855e82bd241f3110dfdf6185899ab"
+dependencies = [
+ "bitflags 2.11.0",
+ "cfg-if",
+ "cssparser",
+ "encoding_rs",
+ "foldhash 0.2.0",
+ "hashbrown 0.16.1",
+ "memchr",
+ "mime",
+ "precomputed-hash",
+ "selectors",
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "lopdf"
version = "0.38.0"
@@ -3528,6 +3746,15 @@ dependencies = [
"weezl",
]
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru"
version = "0.16.3"
@@ -4080,9 +4307,9 @@ dependencies = [
[[package]]
name = "moka"
-version = "0.12.13"
+version = "0.12.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e"
+checksum = "85f8024e1c8e71c778968af91d43700ce1d11b219d127d79fb2934153b82b42b"
dependencies = [
"async-lock",
"crossbeam-channel",
@@ -4246,7 +4473,7 @@ version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
dependencies = [
- "lru",
+ "lru 0.16.3",
"nostr",
"tokio",
]
@@ -4270,7 +4497,7 @@ dependencies = [
"async-wsocket",
"atomic-destructor",
"hex",
- "lru",
+ "lru 0.16.3",
"negentropy",
"nostr",
"nostr-database",
@@ -4383,24 +4610,15 @@ dependencies = [
"objc2-core-foundation",
]
-[[package]]
-name = "object"
-version = "0.36.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
-dependencies = [
- "crc32fast",
- "hashbrown 0.15.5",
- "indexmap",
- "memchr",
-]
-
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
+ "crc32fast",
+ "hashbrown 0.15.5",
+ "indexmap",
"memchr",
]
@@ -4636,6 +4854,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
+ "phf_macros 0.11.3",
"phf_shared 0.11.3",
]
@@ -4654,6 +4873,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
+ "phf_macros 0.13.1",
"phf_shared 0.13.1",
"serde",
]
@@ -4698,6 +4918,32 @@ dependencies = [
"phf_shared 0.13.1",
]
+[[package]]
+name = "phf_macros"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
+dependencies = [
+ "phf_generator 0.11.3",
+ "phf_shared 0.11.3",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "phf_shared"
version = "0.11.3"
@@ -4976,7 +5222,7 @@ dependencies = [
"bincode",
"bitfield",
"bitvec",
- "cobs 0.5.0",
+ "cobs 0.5.1",
"docsplay",
"dunce",
"espflash",
@@ -5094,7 +5340,7 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
- "heck 0.5.0",
+ "heck",
"itertools 0.14.0",
"log",
"multimap",
@@ -5189,14 +5435,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
-name = "pxfm"
-version = "0.1.27"
+name = "pulley-interpreter"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
+checksum = "499d922aa0f9faac8d92351416664f1b7acd914008a90fce2f0516d31efddf67"
dependencies = [
- "num-traits",
+ "cranelift-bitset",
+ "log",
+ "pulley-macros",
+ "wasmtime-internal-math",
]
+[[package]]
+name = "pulley-macros"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3848fb193d6dffca43a21f24ca9492f22aab88af1223d06bac7f8a0ef405b81"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
+
[[package]]
name = "qrcode"
version = "0.14.1"
@@ -5226,7 +5492,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
- "rustc-hash 2.1.1",
+ "rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.18",
@@ -5246,7 +5512,7 @@ dependencies = [
"lru-slab",
"rand 0.9.2",
"ring",
- "rustc-hash 2.1.1",
+ "rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
@@ -5272,9 +5538,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.44"
+version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -5291,6 +5557,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
[[package]]
name = "radium"
version = "0.7.0"
@@ -5335,7 +5607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
dependencies = [
"chacha20 0.10.0",
- "getrandom 0.4.1",
+ "getrandom 0.4.2",
"rand_core 0.10.0",
]
@@ -5398,6 +5670,27 @@ version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags 2.11.0",
+ "cassowary",
+ "compact_str",
+ "crossterm 0.28.1",
+ "indoc",
+ "instability",
+ "itertools 0.13.0",
+ "lru 0.12.5",
+ "paste",
+ "strum 0.26.3",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "rayon"
version = "1.11.0"
@@ -5442,17 +5735,6 @@ dependencies = [
"bitflags 2.11.0",
]
-[[package]]
-name = "redox_users"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
-dependencies = [
- "getrandom 0.2.17",
- "libredox",
- "thiserror 1.0.69",
-]
-
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -5486,14 +5768,15 @@ dependencies = [
[[package]]
name = "regalloc2"
-version = "0.9.3"
+version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad156d539c879b7a24a363a2016d77961786e71f48f2e2fc8302a92abd2429a6"
+checksum = "5216b1837de2149f8bc8e6d5f88a9326b63b8c836ed58ce4a0a29ec736a59734"
dependencies = [
- "hashbrown 0.13.2",
+ "allocator-api2",
+ "bumpalo",
+ "hashbrown 0.15.5",
"log",
- "rustc-hash 1.1.0",
- "slice-group-by",
+ "rustc-hash",
"smallvec",
]
@@ -5836,12 +6119,6 @@ dependencies = [
"walkdir",
]
-[[package]]
-name = "rustc-hash"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
-
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -5966,7 +6243,7 @@ dependencies = [
"nix 0.30.1",
"radix_trie",
"unicode-segmentation",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"utf8parse",
"windows-sys 0.60.2",
]
@@ -6116,6 +6393,25 @@ dependencies = [
"libc",
]
+[[package]]
+name = "selectors"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7"
+dependencies = [
+ "bitflags 2.11.0",
+ "cssparser",
+ "derive_more 2.1.1",
+ "log",
+ "new_debug_unreachable",
+ "phf 0.13.1",
+ "phf_codegen 0.13.1",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
[[package]]
name = "self_cell"
version = "1.2.2"
@@ -6216,16 +6512,6 @@ dependencies = [
"serde_core",
]
-[[package]]
-name = "serde_ignored"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798"
-dependencies = [
- "serde",
- "serde_core",
-]
-
[[package]]
name = "serde_json"
version = "1.0.149"
@@ -6326,6 +6612,15 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
[[package]]
name = "sha1"
version = "0.10.6"
@@ -6363,22 +6658,13 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
-[[package]]
-name = "shellexpand"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4"
-dependencies = [
- "dirs 4.0.0",
-]
-
[[package]]
name = "shellexpand"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8"
dependencies = [
- "dirs 6.0.0",
+ "dirs",
]
[[package]]
@@ -6387,6 +6673,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -6430,12 +6737,6 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
-[[package]]
-name = "slice-group-by"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7"
-
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -6471,12 +6772,6 @@ dependencies = [
"der",
]
-[[package]]
-name = "sptr"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
-
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -6496,6 +6791,12 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
[[package]]
name = "stop-token"
version = "0.7.0"
@@ -6560,13 +6861,35 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros 0.26.4",
+]
+
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.117",
]
[[package]]
@@ -6575,7 +6898,7 @@ version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
- "heck 0.5.0",
+ "heck",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -6659,9 +6982,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "target-lexicon"
-version = "0.12.16"
+version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "tempfile"
@@ -6670,7 +6993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
- "getrandom 0.4.1",
+ "getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -6843,9 +7166,9 @@ dependencies = [
[[package]]
name = "tokio"
-version = "1.49.0"
+version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
+checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
@@ -6859,9 +7182,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
-version = "2.6.0"
+version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
+checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
@@ -7427,6 +7750,17 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -7435,9 +7769,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
-version = "0.2.2"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
[[package]]
name = "unicode-xid"
@@ -7547,7 +7881,7 @@ version = "1.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb"
dependencies = [
- "getrandom 0.4.1",
+ "getrandom 0.4.2",
"js-sys",
"serde_core",
"wasm-bindgen",
@@ -7946,11 +8280,12 @@ dependencies = [
[[package]]
name = "wasm-encoder"
-version = "0.215.0"
+version = "0.236.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fb56df3e06b8e6b77e37d2969a50ba51281029a9aeb3855e76b7f49b6418847"
+checksum = "724fccfd4f3c24b7e589d333fc0429c68042897a7e8a5f8694f31792471841e7"
dependencies = [
- "leb128",
+ "leb128fmt",
+ "wasmparser 0.236.1",
]
[[package]]
@@ -8057,20 +8392,6 @@ dependencies = [
"wasmi_core",
]
-[[package]]
-name = "wasmparser"
-version = "0.215.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53fbde0881f24199b81cf49b6ff8f9c145ac8eb1b7fc439adb5c099734f7d90e"
-dependencies = [
- "ahash",
- "bitflags 2.11.0",
- "hashbrown 0.14.5",
- "indexmap",
- "semver",
- "serde",
-]
-
[[package]]
name = "wasmparser"
version = "0.228.0"
@@ -8081,6 +8402,19 @@ dependencies = [
"indexmap",
]
+[[package]]
+name = "wasmparser"
+version = "0.236.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7"
+dependencies = [
+ "bitflags 2.11.0",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+ "serde",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -8105,21 +8439,22 @@ dependencies = [
[[package]]
name = "wasmprinter"
-version = "0.215.0"
+version = "0.236.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8e9a325d85053408209b3d2ce5eaddd0dd6864d1cff7a007147ba073157defc"
+checksum = "2df225df06a6df15b46e3f73ca066ff92c2e023670969f7d50ce7d5e695abbb1"
dependencies = [
"anyhow",
"termcolor",
- "wasmparser 0.215.0",
+ "wasmparser 0.236.1",
]
[[package]]
name = "wasmtime"
-version = "24.0.6"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3548c6db0acd5c77eae418a2d8b05f963ae6f29be65aed64c652d2aa1eba8b9c"
+checksum = "6a2f8736ddc86e03a9d0e4c477a37939cfc53cd1b052ee38a3133679b87ef830"
dependencies = [
+ "addr2line",
"anyhow",
"async-trait",
"bitflags 2.11.0",
@@ -8127,74 +8462,99 @@ dependencies = [
"cc",
"cfg-if",
"encoding_rs",
- "hashbrown 0.14.5",
+ "hashbrown 0.15.5",
"indexmap",
"libc",
- "libm",
"log",
"mach2 0.4.3",
"memfd",
- "object 0.36.7",
+ "object 0.37.3",
"once_cell",
- "paste",
"postcard",
- "psm",
- "rustix 0.38.44",
+ "pulley-interpreter",
+ "rustix 1.1.4",
"semver",
"serde",
"serde_derive",
"smallvec",
- "sptr",
"target-lexicon",
- "wasmparser 0.215.0",
- "wasmtime-asm-macros",
- "wasmtime-component-macro",
- "wasmtime-component-util",
- "wasmtime-cranelift",
+ "wasmparser 0.236.1",
"wasmtime-environ",
- "wasmtime-fiber",
- "wasmtime-jit-icache-coherence",
- "wasmtime-slab",
- "wasmtime-versioned-export-macros",
- "wasmtime-winch",
- "windows-sys 0.52.0",
+ "wasmtime-internal-asm-macros",
+ "wasmtime-internal-component-macro",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-fiber",
+ "wasmtime-internal-jit-debug",
+ "wasmtime-internal-jit-icache-coherence",
+ "wasmtime-internal-math",
+ "wasmtime-internal-slab",
+ "wasmtime-internal-unwinder",
+ "wasmtime-internal-versioned-export-macros",
+ "wasmtime-internal-winch",
+ "windows-sys 0.60.2",
]
[[package]]
-name = "wasmtime-asm-macros"
-version = "24.0.6"
+name = "wasmtime-environ"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b78a28fc6b83b1f805d61a01aa0426f2f17b37110f86029b7d68ab105243d023"
+checksum = "733682a327755c77153ac7455b1ba8f2db4d9946c1738f8002fe1fbda1d52e83"
+dependencies = [
+ "anyhow",
+ "cranelift-bitset",
+ "cranelift-entity",
+ "gimli",
+ "indexmap",
+ "log",
+ "object 0.37.3",
+ "postcard",
+ "semver",
+ "serde",
+ "serde_derive",
+ "smallvec",
+ "target-lexicon",
+ "wasm-encoder 0.236.1",
+ "wasmparser 0.236.1",
+ "wasmprinter",
+ "wasmtime-internal-component-util",
+]
+
+[[package]]
+name = "wasmtime-internal-asm-macros"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68288980a2e02bcb368d436da32565897033ea21918007e3f2bae18843326cf9"
dependencies = [
"cfg-if",
]
[[package]]
-name = "wasmtime-component-macro"
-version = "24.0.6"
+name = "wasmtime-internal-component-macro"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d22bdf9af333562df78e1b841a3e5a2e99a1243346db973f1af42b93cb97732"
+checksum = "5dea846da68f8e776c8a43bde3386022d7bb74e713b9654f7c0196e5ff2e4684"
dependencies = [
"anyhow",
"proc-macro2",
"quote",
"syn 2.0.117",
- "wasmtime-component-util",
- "wasmtime-wit-bindgen",
- "wit-parser 0.215.0",
+ "wasmtime-internal-component-util",
+ "wasmtime-internal-wit-bindgen",
+ "wit-parser 0.236.1",
]
[[package]]
-name = "wasmtime-component-util"
-version = "24.0.6"
+name = "wasmtime-internal-component-util"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ace6645ada74c365f94d50f8bd31e383aa5bd419bfaad873f5227768ed33bd99"
+checksum = "fe1e5735b3c8251510d2a55311562772d6c6fca9438a3d0329eb6e38af4957d6"
[[package]]
-name = "wasmtime-cranelift"
-version = "24.0.6"
+name = "wasmtime-internal-cranelift"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f29888e14ff69a85bc7ca286f0720dcdc79a6ff01f0fc013a1a1a39697778e54"
+checksum = "e89bb9ef571288e2be6b8a3c4763acc56c348dcd517500b1679d3ffad9e4a757"
dependencies = [
"anyhow",
"cfg-if",
@@ -8203,94 +8563,91 @@ dependencies = [
"cranelift-entity",
"cranelift-frontend",
"cranelift-native",
- "cranelift-wasm",
- "gimli 0.29.0",
+ "gimli",
+ "itertools 0.14.0",
"log",
- "object 0.36.7",
+ "object 0.37.3",
+ "pulley-interpreter",
+ "smallvec",
"target-lexicon",
- "thiserror 1.0.69",
- "wasmparser 0.215.0",
+ "thiserror 2.0.18",
+ "wasmparser 0.236.1",
"wasmtime-environ",
- "wasmtime-versioned-export-macros",
+ "wasmtime-internal-math",
+ "wasmtime-internal-versioned-export-macros",
]
[[package]]
-name = "wasmtime-environ"
-version = "24.0.6"
+name = "wasmtime-internal-fiber"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8978792f7fa4c1c8a11c366880e3b52f881f7382203bee971dd7381b86123ee0"
-dependencies = [
- "anyhow",
- "cranelift-bitset",
- "cranelift-entity",
- "gimli 0.29.0",
- "indexmap",
- "log",
- "object 0.36.7",
- "postcard",
- "semver",
- "serde",
- "serde_derive",
- "target-lexicon",
- "wasm-encoder 0.215.0",
- "wasmparser 0.215.0",
- "wasmprinter",
- "wasmtime-component-util",
- "wasmtime-types",
-]
-
-[[package]]
-name = "wasmtime-fiber"
-version = "24.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5a8996adf4964933b37488f55d1a8ba5da1aed9201fea678aa44f09814ec24c"
+checksum = "b698d004b15ea1f1ae2d06e5e8b80080cbd684fd245220ce2fac3cdd5ecf87f2"
dependencies = [
"anyhow",
"cc",
"cfg-if",
- "rustix 0.38.44",
- "wasmtime-asm-macros",
- "wasmtime-versioned-export-macros",
- "windows-sys 0.52.0",
+ "libc",
+ "rustix 1.1.4",
+ "wasmtime-internal-asm-macros",
+ "wasmtime-internal-versioned-export-macros",
+ "windows-sys 0.60.2",
]
[[package]]
-name = "wasmtime-jit-icache-coherence"
-version = "24.0.6"
+name = "wasmtime-internal-jit-debug"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69bb9a6ff1d8f92789cc2a3da13eed4074de65cceb62224cb3d8b306533b7884"
+checksum = "c803a9fec05c3d7fa03474d4595079d546e77a3c71c1d09b21f74152e2165c17"
+dependencies = [
+ "cc",
+ "wasmtime-internal-versioned-export-macros",
+]
+
+[[package]]
+name = "wasmtime-internal-jit-icache-coherence"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3866909d37f7929d902e6011847748147e8734e9d7e0353e78fb8b98f586aee"
dependencies = [
"anyhow",
"cfg-if",
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
-name = "wasmtime-slab"
-version = "24.0.6"
+name = "wasmtime-internal-math"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec8ac1f5bcfc8038c60b1a0a9116d5fb266ac5ee1529640c1fe763c9bcaa8a9b"
+checksum = "5a23b03fb14c64bd0dfcaa4653101f94ade76c34a3027ed2d6b373267536e45b"
+dependencies = [
+ "libm",
+]
[[package]]
-name = "wasmtime-types"
-version = "24.0.6"
+name = "wasmtime-internal-slab"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "511ad6ede0cfcb30718b1a378e66022d60d942d42a33fbf5c03c5d8db48d52b9"
+checksum = "fbff220b88cdb990d34a20b13344e5da2e7b99959a5b1666106bec94b58d6364"
+
+[[package]]
+name = "wasmtime-internal-unwinder"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13e1ad30e88988b20c0d1c56ea4b4fbc01a8c614653cbf12ca50c0dcc695e2f7"
dependencies = [
"anyhow",
- "cranelift-entity",
- "serde",
- "serde_derive",
- "smallvec",
- "wasmparser 0.215.0",
+ "cfg-if",
+ "cranelift-codegen",
+ "log",
+ "object 0.37.3",
]
[[package]]
-name = "wasmtime-versioned-export-macros"
-version = "24.0.6"
+name = "wasmtime-internal-versioned-export-macros"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10283bdd96381b62e9f527af85459bf4c4824a685a882c8886e2b1cdb2f36198"
+checksum = "549aefdaa1398c2fcfbf69a7b882956bb5b6e8e5b600844ecb91a3b5bf658ca7"
dependencies = [
"proc-macro2",
"quote",
@@ -8298,10 +8655,40 @@ dependencies = [
]
[[package]]
-name = "wasmtime-wasi"
-version = "24.0.6"
+name = "wasmtime-internal-winch"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34e407b075122508c38a0d80baf5313754ac685338626365d3deb70149aa8626"
+checksum = "5cc96a84c5700171aeecf96fa9a9ab234f333f5afb295dabf3f8a812b70fe832"
+dependencies = [
+ "anyhow",
+ "cranelift-codegen",
+ "gimli",
+ "object 0.37.3",
+ "target-lexicon",
+ "wasmparser 0.236.1",
+ "wasmtime-environ",
+ "wasmtime-internal-cranelift",
+ "winch-codegen",
+]
+
+[[package]]
+name = "wasmtime-internal-wit-bindgen"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28dc9efea511598c88564ac1974e0825c07d9c0de902dbf68f227431cd4ff8c"
+dependencies = [
+ "anyhow",
+ "bitflags 2.11.0",
+ "heck",
+ "indexmap",
+ "wit-parser 0.236.1",
+]
+
+[[package]]
+name = "wasmtime-wasi"
+version = "36.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3c2e99fbaa0c26b4680e0c9af07e3f7b25f5fbc1ad97dd34067980bd027d3e5"
dependencies = [
"anyhow",
"async-trait",
@@ -8316,45 +8703,29 @@ dependencies = [
"futures",
"io-extras",
"io-lifetimes",
- "once_cell",
- "rustix 0.38.44",
+ "rustix 1.1.4",
"system-interface",
- "thiserror 1.0.69",
+ "thiserror 2.0.18",
"tokio",
"tracing",
"url",
"wasmtime",
+ "wasmtime-wasi-io",
"wiggle",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
-name = "wasmtime-winch"
-version = "24.0.6"
+name = "wasmtime-wasi-io"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc90b7318c0747d937adbecde67a0727fbd7d26b9fbb4ca68449c0e94b3db24b"
+checksum = "de2dc367052562c228ce51ee4426330840433c29c0ea3349eca5ddeb475ecdb9"
dependencies = [
"anyhow",
- "cranelift-codegen",
- "gimli 0.29.0",
- "object 0.36.7",
- "target-lexicon",
- "wasmparser 0.215.0",
- "wasmtime-cranelift",
- "wasmtime-environ",
- "winch-codegen",
-]
-
-[[package]]
-name = "wasmtime-wit-bindgen"
-version = "24.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "beb8b981b1982ae3aa83567348cbb68598a2a123646e4aa604a3b5c1804f3383"
-dependencies = [
- "anyhow",
- "heck 0.4.1",
- "indexmap",
- "wit-parser 0.215.0",
+ "async-trait",
+ "bytes",
+ "futures",
+ "wasmtime",
]
[[package]]
@@ -8375,7 +8746,7 @@ dependencies = [
"bumpalo",
"leb128fmt",
"memchr",
- "unicode-width 0.2.2",
+ "unicode-width 0.2.0",
"wasm-encoder 0.245.1",
]
@@ -8491,14 +8862,14 @@ dependencies = [
[[package]]
name = "wiggle"
-version = "24.0.6"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3873cfb2841fe04a2a5d09c2f84770738e67d944b7c375246d6900be2723da52"
+checksum = "c13d1ae265bd6e5e608827d2535665453cae5cb64950de66e2d5767d3e32c43a"
dependencies = [
"anyhow",
"async-trait",
"bitflags 2.11.0",
- "thiserror 1.0.69",
+ "thiserror 2.0.18",
"tracing",
"wasmtime",
"wiggle-macro",
@@ -8506,24 +8877,23 @@ dependencies = [
[[package]]
name = "wiggle-generate"
-version = "24.0.6"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8074d4528c162030bbafde77d7ded488f30fb1ff7732970c8293b9425c517d53"
+checksum = "607c4966f6b30da20d24560220137cbd09df722f0558eac81c05624700af5e05"
dependencies = [
"anyhow",
- "heck 0.4.1",
+ "heck",
"proc-macro2",
"quote",
- "shellexpand 2.1.2",
"syn 2.0.117",
"witx",
]
[[package]]
name = "wiggle-macro"
-version = "24.0.6"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7e4a8840138ac6170c6d16277680eb4f6baada47bc8a2678d66f264e00de966"
+checksum = "fc36e39412fa35f7cc86b3705dbe154168721dd3e71f6dc4a726b266d5c60c55"
dependencies = [
"proc-macro2",
"quote",
@@ -8570,19 +8940,22 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
-version = "0.22.6"
+version = "36.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "779a8c6f82a64f1ac941a928479868f6fffae86a4fc3a1e23b1d8cb3caddd7f2"
+checksum = "06c0ec09e8eb5e850e432da6271ed8c4a9d459a9db3850c38e98a3ee9d015e79"
dependencies = [
"anyhow",
+ "cranelift-assembler-x64",
"cranelift-codegen",
- "gimli 0.29.0",
+ "gimli",
"regalloc2",
"smallvec",
"target-lexicon",
- "wasmparser 0.215.0",
- "wasmtime-cranelift",
+ "thiserror 2.0.18",
+ "wasmparser 0.236.1",
"wasmtime-environ",
+ "wasmtime-internal-cranelift",
+ "wasmtime-internal-math",
]
[[package]]
@@ -8882,7 +9255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
- "heck 0.5.0",
+ "heck",
"wit-parser 0.244.0",
]
@@ -8893,7 +9266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
- "heck 0.5.0",
+ "heck",
"indexmap",
"prettyplease",
"syn 2.0.117",
@@ -8938,9 +9311,9 @@ dependencies = [
[[package]]
name = "wit-parser"
-version = "0.215.0"
+version = "0.236.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "935a97eaffd57c3b413aa510f8f0b550a4a9fe7d59e79cd8b89a83dcb860321f"
+checksum = "16e4833a20cd6e85d6abfea0e63a399472d6f88c6262957c17f546879a80ba15"
dependencies = [
"anyhow",
"id-arena",
@@ -8951,7 +9324,7 @@ dependencies = [
"serde_derive",
"serde_json",
"unicode-xid",
- "wasmparser 0.215.0",
+ "wasmparser 0.236.1",
]
[[package]]
@@ -9084,7 +9457,7 @@ dependencies = [
[[package]]
name = "zeroclaw"
-version = "0.1.7"
+version = "0.1.8"
dependencies = [
"aho-corasick",
"anyhow",
@@ -9100,9 +9473,11 @@ dependencies = [
"console",
"criterion",
"cron",
+ "crossterm 0.29.0",
"dialoguer",
"directories",
"fantoccini",
+ "fast_html2md",
"futures-util",
"glob",
"hex",
@@ -9131,6 +9506,7 @@ dependencies = [
"qrcode",
"quick-xml",
"rand 0.10.0",
+ "ratatui",
"regex",
"reqwest",
"ring",
@@ -9144,10 +9520,9 @@ dependencies = [
"scopeguard",
"serde",
"serde-big-array",
- "serde_ignored",
"serde_json",
"sha2",
- "shellexpand 3.1.2",
+ "shellexpand",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -9179,6 +9554,13 @@ dependencies = [
"zip",
]
+[[package]]
+name = "zeroclaw-core"
+version = "0.1.0"
+dependencies = [
+ "zeroclaw-types",
+]
+
[[package]]
name = "zeroclaw-robot-kit"
version = "0.1.0"
@@ -9200,6 +9582,10 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "zeroclaw-types"
+version = "0.1.0"
+
[[package]]
name = "zerocopy"
version = "0.8.40"
@@ -9318,9 +9704,9 @@ dependencies = [
[[package]]
name = "zip"
-version = "8.1.0"
+version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e499faf5c6b97a0d086f4a8733de6d47aee2252b8127962439d8d4311a73f72"
+checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
dependencies = [
"crc32fast",
"flate2",
@@ -9332,9 +9718,9 @@ dependencies = [
[[package]]
name = "zlib-rs"
-version = "0.6.2"
+version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c745c48e1007337ed136dc99df34128b9faa6ed542d80a1c673cf55a6d7236c8"
+checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513"
[[package]]
name = "zmij"
diff --git a/Cargo.toml b/Cargo.toml
index a5ee3312c..05ce52d4f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,11 +1,17 @@
[workspace]
-members = [".", "crates/robot-kit"]
+members = [
+ ".",
+ "crates/robot-kit",
+ "crates/zeroclaw-types",
+ "crates/zeroclaw-core",
+]
resolver = "2"
[package]
name = "zeroclaw"
-version = "0.1.7"
+version = "0.1.8"
edition = "2021"
+build = "build.rs"
authors = ["theonlyhennygod"]
license = "MIT OR Apache-2.0"
description = "Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant."
@@ -34,7 +40,6 @@ matrix-sdk = { version = "0.16", optional = true, default-features = false, feat
# Serialization
serde = { version = "1.0", default-features = false, features = ["derive"] }
serde_json = { version = "1.0", default-features = false, features = ["std"] }
-serde_ignored = "0.1"
# Config
directories = "6.0"
@@ -58,8 +63,9 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png"]
# URL encoding for web search
urlencoding = "2.1"
-# HTML to plain text conversion (web_fetch tool)
+# HTML to plain text / markdown conversion (web_fetch tool)
nanohtml2text = "0.2"
+html2md = { package = "fast_html2md", version = "0.0.58", optional = true }
# Zip archive extraction
zip = { version = "8.1", default-features = false, features = ["deflate"] }
@@ -119,6 +125,8 @@ cron = "0.15"
dialoguer = { version = "0.12", features = ["fuzzy-select"] }
rustyline = "17.0"
console = "0.16"
+crossterm = "0.29"
+ratatui = { version = "0.29", default-features = false, features = ["crossterm"] }
# Hardware discovery (device path globbing)
glob = "0.3"
@@ -163,6 +171,11 @@ opentelemetry = { version = "0.31", default-features = false, features = ["trace
opentelemetry_sdk = { version = "0.31", default-features = false, features = ["trace", "metrics"], optional = true }
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-blocking-client", "reqwest-rustls-webpki-roots"], optional = true }
+# WASM runtime for plugin execution
+# Keep this on a RustSec-patched line that remains compatible with the
+# workspace rust-version = "1.87".
+wasmtime = { version = "36.0.6", default-features = false, features = ["runtime", "cranelift"] }
+
# Serial port for peripheral communication (STM32, etc.)
tokio-serial = { version = "5", default-features = false, optional = true }
@@ -180,8 +193,7 @@ tempfile = "3.14"
# WASM plugin runtime (optional, enable with --features wasm-tools)
# Uses WASI stdio protocol — tools read JSON from stdin, write JSON to stdout.
-wasmtime = { version = "24.0.6", optional = true, default-features = false, features = ["cranelift", "runtime"] }
-wasmtime-wasi = { version = "24.0.6", optional = true, default-features = false, features = ["preview1"] }
+wasmtime-wasi = { version = "36.0.6", optional = true, default-features = false, features = ["preview1"] }
# Terminal QR rendering for WhatsApp Web pairing flow.
qrcode = { version = "0.14", optional = true }
@@ -205,9 +217,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"]
+# Keep default minimal for widest host compatibility (including macOS 10.15).
+default = []
hardware = ["nusb", "tokio-serial"]
channel-matrix = ["dep:matrix-sdk"]
channel-lark = ["dep:prost"]
@@ -231,13 +242,13 @@ probe = ["dep:probe-rs"]
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"]
+wasm-tools = ["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-html2md = ["dep:html2md"]
[profile.release]
opt-level = "z" # Optimize for size
@@ -249,8 +260,9 @@ panic = "abort" # Reduce binary size
[profile.release-fast]
inherits = "release"
-codegen-units = 8 # Parallel codegen for faster builds on powerful machines (16GB+ RAM recommended)
- # Use: cargo build --profile release-fast
+# Keep release-fast under CI binary size safeguard (20MB hard gate).
+# Using 1 codegen unit preserves release-level size characteristics.
+codegen-units = 1
[profile.dist]
inherits = "release"
diff --git a/Dockerfile b/Dockerfile
index e8e9ded74..230e3056d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -5,31 +5,40 @@ FROM rust:1.93-slim@sha256:7e6fa79cf81be23fd45d857f75f583d80cfdbb11c91fa06180fd7
WORKDIR /app
ARG ZEROCLAW_CARGO_FEATURES=""
+ARG ZEROCLAW_CARGO_ALL_FEATURES="false"
# Install build dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y \
+ libudev-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# 1. Copy manifests to cache dependencies
COPY Cargo.toml Cargo.lock ./
+COPY build.rs build.rs
COPY crates/robot-kit/Cargo.toml crates/robot-kit/Cargo.toml
+COPY crates/zeroclaw-types/Cargo.toml crates/zeroclaw-types/Cargo.toml
+COPY crates/zeroclaw-core/Cargo.toml crates/zeroclaw-core/Cargo.toml
# Create dummy targets declared in Cargo.toml so manifest parsing succeeds.
-RUN mkdir -p src benches crates/robot-kit/src \
+RUN mkdir -p src benches crates/robot-kit/src crates/zeroclaw-types/src crates/zeroclaw-core/src \
&& echo "fn main() {}" > src/main.rs \
&& echo "fn main() {}" > benches/agent_benchmarks.rs \
- && echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs
+ && echo "pub fn placeholder() {}" > crates/robot-kit/src/lib.rs \
+ && echo "pub fn placeholder() {}" > crates/zeroclaw-types/src/lib.rs \
+ && echo "pub fn placeholder() {}" > crates/zeroclaw-core/src/lib.rs
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
- if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
- cargo build --release --features "$ZEROCLAW_CARGO_FEATURES"; \
+ if [ "$ZEROCLAW_CARGO_ALL_FEATURES" = "true" ]; then \
+ cargo build --release --locked --all-features; \
+ elif [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
+ cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
else \
cargo build --release --locked; \
fi
-RUN rm -rf src benches crates/robot-kit/src
+RUN rm -rf src benches crates/robot-kit/src crates/zeroclaw-types/src crates/zeroclaw-core/src
# 2. Copy only build-relevant source paths (avoid cache-busting on docs/tests/scripts)
COPY src/ src/
@@ -58,8 +67,10 @@ RUN mkdir -p web/dist && \
RUN --mount=type=cache,id=zeroclaw-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=zeroclaw-cargo-git,target=/usr/local/cargo/git,sharing=locked \
--mount=type=cache,id=zeroclaw-target,target=/app/target,sharing=locked \
- if [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
- cargo build --release --features "$ZEROCLAW_CARGO_FEATURES"; \
+ if [ "$ZEROCLAW_CARGO_ALL_FEATURES" = "true" ]; then \
+ cargo build --release --locked --all-features; \
+ elif [ -n "$ZEROCLAW_CARGO_FEATURES" ]; then \
+ cargo build --release --locked --features "$ZEROCLAW_CARGO_FEATURES"; \
else \
cargo build --release --locked; \
fi && \
diff --git a/README.md b/README.md
index f9ea19ae8..5cacdee2c 100644
--- a/README.md
+++ b/README.md
@@ -25,12 +25,12 @@ Built by students and members of the Harvard, MIT, and Sundai.Club communities.
- 🌐 Languages: English · 简体中文 · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά
+ 🌐 Languages: English · 简体中文 · Español · Português · Italiano · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά
Getting Started |
- One-Click Setup |
+ One-Click Setup |
Docs Hub |
Docs TOC
@@ -46,12 +46,12 @@ Built by students and members of the Harvard, MIT, and Sundai.Club communities.
- Fast, small, and fully autonomous Operating System
+ Fast, small, and fully autonomous Framework
Deploy anywhere. Swap anything.
- ZeroClaw is the runtime operating system for agentic workflows — infrastructure that abstracts models, tools, memory, and execution so agents can be built once and run anywhere.
+ ZeroClaw is the runtime framework for agentic workflows — infrastructure that abstracts models, tools, memory, and execution so agents can be built once and run anywhere.
Trait-driven architecture · secure-by-default runtime · provider/channel/tool swappable · pluggable everything
@@ -83,6 +83,12 @@ Use this board for important notices (breaking changes, security advisories, mai
## Quick Start
+### Option 0: One-line Installer (Default TUI Onboarding)
+
+```bash
+curl -fsSL https://zeroclawlabs.ai/install.sh | bash
+```
+
### Option 1: Homebrew (macOS/Linuxbrew)
```bash
@@ -108,11 +114,11 @@ cargo install zeroclaw
### First Run
```bash
-# Start the gateway daemon
-zeroclaw gateway start
+# Start the gateway (serves the Web Dashboard API/UI)
+zeroclaw gateway
-# Open the web UI
-zeroclaw dashboard
+# Open the dashboard URL shown in startup logs
+# (default: http://127.0.0.1:3000/)
# Or chat directly
zeroclaw chat "Hello!"
@@ -120,6 +126,16 @@ zeroclaw chat "Hello!"
For detailed setup options, see [docs/one-click-bootstrap.md](docs/one-click-bootstrap.md).
+### Installation Docs (Canonical Source)
+
+Use repository docs as the source of truth for install/setup instructions:
+
+- [README Quick Start](#quick-start)
+- [docs/one-click-bootstrap.md](docs/one-click-bootstrap.md)
+- [docs/getting-started/README.md](docs/getting-started/README.md)
+
+Issue comments can provide context, but they are not canonical installation documentation.
+
## Benchmark Snapshot (ZeroClaw vs OpenClaw, Reproducible)
Local machine quick benchmark (macOS arm64, Feb 2026) normalized for 0.8GHz edge hardware.
diff --git a/RUN_TESTS.md b/RUN_TESTS.md
index eddc5785c..3af1241d6 100644
--- a/RUN_TESTS.md
+++ b/RUN_TESTS.md
@@ -13,6 +13,8 @@
cargo test telegram --lib
```
+Toolchain note: CI/release metadata is aligned with Rust `1.88`; use the same stable toolchain when reproducing release-facing checks locally.
+
## 📝 What Was Created For You
### 1. **test_telegram_integration.sh** (Main Test Suite)
@@ -298,6 +300,6 @@ If all tests pass:
## 📞 Support
-- Issues: https://github.com/theonlyhennygod/zeroclaw/issues
+- Issues: https://github.com/zeroclaw-labs/zeroclaw/issues
- Docs: `./TESTING_TELEGRAM.md`
- Help: `zeroclaw --help`
diff --git a/TESTING_TELEGRAM.md b/TESTING_TELEGRAM.md
index 7a09c6fbd..1eda0acd1 100644
--- a/TESTING_TELEGRAM.md
+++ b/TESTING_TELEGRAM.md
@@ -115,6 +115,9 @@ After running automated tests, perform these manual checks:
- Send message with @botname mention
- Verify: Bot responds and mention is stripped
- DM/private chat should always work regardless of mention_only
+ - Regression check (group non-text): verify group media without mention does not trigger bot reply
+ - Regression command:
+ `cargo test -q telegram_mention_only_group_photo_without_caption_is_ignored`
6. **Error logging**
@@ -349,4 +352,4 @@ zeroclaw channel doctor
- [Telegram Bot API Documentation](https://core.telegram.org/bots/api)
- [ZeroClaw Main README](README.md)
- [Contributing Guide](CONTRIBUTING.md)
-- [Issue Tracker](https://github.com/theonlyhennygod/zeroclaw/issues)
+- [Issue Tracker](https://github.com/zeroclaw-labs/zeroclaw/issues)
diff --git a/benches/agent_benchmarks.rs b/benches/agent_benchmarks.rs
index c6441d238..baeb9d52c 100644
--- a/benches/agent_benchmarks.rs
+++ b/benches/agent_benchmarks.rs
@@ -42,6 +42,8 @@ impl BenchProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
}]),
}
}
@@ -59,6 +61,8 @@ impl BenchProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
},
ChatResponse {
text: Some("done".into()),
@@ -66,6 +70,8 @@ impl BenchProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
},
]),
}
@@ -98,6 +104,8 @@ impl Provider for BenchProvider {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@@ -166,6 +174,8 @@ Let me know if you need more."#
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
};
let multi_tool = ChatResponse {
@@ -185,6 +195,8 @@ Let me know if you need more."#
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
};
c.bench_function("xml_parse_single_tool_call", |b| {
@@ -220,6 +232,8 @@ fn bench_native_parsing(c: &mut Criterion) {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
};
c.bench_function("native_parse_tool_calls", |b| {
diff --git a/build.rs b/build.rs
new file mode 100644
index 000000000..9d8b6a704
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,80 @@
+use std::env;
+use std::path::PathBuf;
+use std::process::Command;
+
+fn git_short_sha(manifest_dir: &str) -> Option {
+ let output = Command::new("git")
+ .args(["rev-parse", "--short", "HEAD"])
+ .current_dir(manifest_dir)
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let short_sha = String::from_utf8(output.stdout).ok()?;
+ let trimmed = short_sha.trim();
+ if trimmed.is_empty() {
+ None
+ } else {
+ Some(trimmed.to_string())
+ }
+}
+
+fn emit_git_rerun_hints(manifest_dir: &str) {
+ let output = Command::new("git")
+ .args(["rev-parse", "--git-dir"])
+ .current_dir(manifest_dir)
+ .output();
+
+ let Ok(output) = output else {
+ return;
+ };
+ if !output.status.success() {
+ return;
+ }
+
+ let Ok(git_dir_raw) = String::from_utf8(output.stdout) else {
+ return;
+ };
+ let git_dir_raw = git_dir_raw.trim();
+ if git_dir_raw.is_empty() {
+ return;
+ }
+
+ let git_dir = if PathBuf::from(git_dir_raw).is_absolute() {
+ PathBuf::from(git_dir_raw)
+ } else {
+ PathBuf::from(manifest_dir).join(git_dir_raw)
+ };
+
+ println!("cargo:rerun-if-changed={}", git_dir.join("HEAD").display());
+ println!("cargo:rerun-if-changed={}", git_dir.join("refs").display());
+}
+
+fn main() {
+ println!("cargo:rerun-if-changed=build.rs");
+ println!("cargo:rerun-if-env-changed=ZEROCLAW_GIT_SHORT_SHA");
+
+ let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
+ emit_git_rerun_hints(&manifest_dir);
+
+ let package_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
+ let short_sha = env::var("ZEROCLAW_GIT_SHORT_SHA")
+ .ok()
+ .filter(|v| !v.trim().is_empty())
+ .or_else(|| git_short_sha(&manifest_dir));
+
+ let build_version = if let Some(sha) = short_sha.as_deref() {
+ format!("{package_version} ({sha})")
+ } else {
+ package_version
+ };
+
+ println!("cargo:rustc-env=ZEROCLAW_BUILD_VERSION={build_version}");
+ println!(
+ "cargo:rustc-env=ZEROCLAW_GIT_SHORT_SHA={}",
+ short_sha.unwrap_or_default()
+ );
+}
diff --git a/crates/robot-kit/PI5_SETUP.md b/crates/robot-kit/PI5_SETUP.md
index 5e90a5f2c..417ef8070 100644
--- a/crates/robot-kit/PI5_SETUP.md
+++ b/crates/robot-kit/PI5_SETUP.md
@@ -171,7 +171,7 @@ sudo usermod -aG dialout $USER
```bash
# Clone repo (or copy from USB)
-git clone https://github.com/theonlyhennygod/zeroclaw
+git clone https://github.com/zeroclaw-labs/zeroclaw
cd zeroclaw
# Build robot kit
diff --git a/crates/zeroclaw-core/Cargo.toml b/crates/zeroclaw-core/Cargo.toml
new file mode 100644
index 000000000..47e1f1315
--- /dev/null
+++ b/crates/zeroclaw-core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "zeroclaw-core"
+version = "0.1.0"
+edition = "2021"
+license = "MIT OR Apache-2.0"
+description = "Core contracts and boundaries for staged multi-crate extraction."
+
+[lib]
+path = "src/lib.rs"
+
+[dependencies]
+zeroclaw-types = { path = "../zeroclaw-types" }
diff --git a/crates/zeroclaw-core/src/lib.rs b/crates/zeroclaw-core/src/lib.rs
new file mode 100644
index 000000000..9040040b8
--- /dev/null
+++ b/crates/zeroclaw-core/src/lib.rs
@@ -0,0 +1,8 @@
+#![forbid(unsafe_code)]
+
+//! Core contracts for the staged workspace split.
+//!
+//! This crate is intentionally minimal in PR-1 (scaffolding only).
+
+/// Marker constant proving dependency linkage to `zeroclaw-types`.
+pub const CORE_CRATE_ID: &str = zeroclaw_types::CRATE_ID;
diff --git a/crates/zeroclaw-types/Cargo.toml b/crates/zeroclaw-types/Cargo.toml
new file mode 100644
index 000000000..2b3ff2eb5
--- /dev/null
+++ b/crates/zeroclaw-types/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "zeroclaw-types"
+version = "0.1.0"
+edition = "2021"
+license = "MIT OR Apache-2.0"
+description = "Foundational shared types for staged multi-crate extraction."
+
+[lib]
+path = "src/lib.rs"
diff --git a/crates/zeroclaw-types/src/lib.rs b/crates/zeroclaw-types/src/lib.rs
new file mode 100644
index 000000000..95c3cf66a
--- /dev/null
+++ b/crates/zeroclaw-types/src/lib.rs
@@ -0,0 +1,8 @@
+#![forbid(unsafe_code)]
+
+//! Shared foundational types for the staged workspace split.
+//!
+//! This crate is intentionally minimal in PR-1 (scaffolding only).
+
+/// Marker constant proving the crate is linked in workspace checks.
+pub const CRATE_ID: &str = "zeroclaw-types";
diff --git a/docs/README.md b/docs/README.md
index 05d6c6cb1..317ae8422 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -29,6 +29,8 @@ Localized hubs: [简体中文](i18n/zh-CN/README.md) · [日本語](i18n/ja/READ
| See project PR/issue docs snapshot | [project-triage-snapshot-2026-02-18.md](project-triage-snapshot-2026-02-18.md) |
| Perform i18n completion for docs changes | [i18n-guide.md](i18n-guide.md) |
+Installation source-of-truth: keep install/run instructions in repository docs and README pages; issue comments are supplemental context only.
+
## Quick Decision Tree (10 seconds)
- Need first-time setup or install? → [getting-started/README.md](getting-started/README.md)
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index fe91dc26a..b6b81dd95 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -94,6 +94,7 @@ Last refreshed: **February 28, 2026**.
- [pr-workflow.md](pr-workflow.md)
- [reviewer-playbook.md](reviewer-playbook.md)
- [ci-map.md](ci-map.md)
+- [ci-blacksmith.md](ci-blacksmith.md)
- [actions-source-policy.md](actions-source-policy.md)
- [cargo-slicer-speedup.md](cargo-slicer-speedup.md)
@@ -111,5 +112,7 @@ Last refreshed: **February 28, 2026**.
- [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)
+- [project/f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md](project/f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md)
+- [project/q0-3-stop-reason-state-machine-rfi-2026-03-01.md](project/q0-3-stop-reason-state-machine-rfi-2026-03-01.md)
- [i18n-gap-backlog.md](i18n-gap-backlog.md)
- [docs-inventory.md](docs-inventory.md)
diff --git a/docs/arduino-uno-q-setup.md b/docs/arduino-uno-q-setup.md
index 1f333c19c..3bcb85378 100644
--- a/docs/arduino-uno-q-setup.md
+++ b/docs/arduino-uno-q-setup.md
@@ -66,7 +66,7 @@ sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev
# Clone zeroclaw (or scp your project)
-git clone https://github.com/theonlyhennygod/zeroclaw.git
+git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
# Build (takes ~15–30 min on Uno Q)
@@ -199,7 +199,7 @@ Now when you message your Telegram bot *"Turn on the LED"* or *"Set pin 13 high"
| 2 | `ssh arduino@` |
| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` |
| 4 | `sudo apt-get install -y pkg-config libssl-dev` |
-| 5 | `git clone https://github.com/theonlyhennygod/zeroclaw.git && cd zeroclaw` |
+| 5 | `git clone https://github.com/zeroclaw-labs/zeroclaw.git && cd zeroclaw` |
| 6 | `cargo build --release --features hardware` |
| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` |
| 8 | Edit `~/.zeroclaw/config.toml` (add Telegram bot_token) |
diff --git a/docs/channels-reference.md b/docs/channels-reference.md
index 759e36da6..1327bc71c 100644
--- a/docs/channels-reference.md
+++ b/docs/channels-reference.md
@@ -56,6 +56,8 @@ Telegram/Discord sender-scoped model routing:
Supervised tool approvals (all non-CLI channels):
- `/approve-request ` — create a pending approval request
- `/approve-confirm ` — confirm pending request (same sender + same chat/channel only)
+- `/approve-allow ` — approve the current pending runtime execution request once (no policy persistence)
+- `/approve-deny ` — deny the current pending runtime execution request
- `/approve-pending` — list pending requests for your current sender+chat/channel scope
- `/approve ` — direct one-step approve + persist (`autonomy.auto_approve`, compatibility path)
- `/unapprove ` — revoke and remove persisted approval
@@ -76,6 +78,7 @@ Notes:
- You can restrict who can use approval-management commands via `[autonomy].non_cli_approval_approvers`.
- Configure natural-language approval mode via `[autonomy].non_cli_natural_language_approval_mode`.
- `autonomy.non_cli_excluded_tools` is reloaded from `config.toml` at runtime; `/approvals` shows the currently effective list.
+- Default non-CLI exclusions include both `shell` and `process`; remove `process` from `[autonomy].non_cli_excluded_tools` only when you explicitly want background command execution in chat channels.
- Each incoming message injects a runtime tool-availability snapshot into the system prompt, derived from the same exclusion policy used by execution.
## Inbound Image Marker Protocol
@@ -145,6 +148,7 @@ If `[channels_config.matrix]`, `[channels_config.lark]`, or `[channels_config.fe
| QQ | bot gateway | No |
| Napcat | websocket receive + HTTP send (OneBot) | No (typically local/LAN) |
| Linq | webhook (`/linq`) | Yes (public HTTPS callback) |
+| WATI | webhook (`/wati`) | Yes (public HTTPS callback) |
| iMessage | local integration | No |
| ACP | stdio (JSON-RPC 2.0) | No |
| Nostr | relay websocket (NIP-04 / NIP-17) | No |
@@ -163,7 +167,7 @@ Field names differ by channel:
- `allowed_users` (Telegram/Discord/Slack/Mattermost/Matrix/IRC/Lark/Feishu/DingTalk/QQ/Napcat/Nextcloud Talk/ACP)
- `allowed_from` (Signal)
-- `allowed_numbers` (WhatsApp)
+- `allowed_numbers` (WhatsApp/WATI)
- `allowed_senders` (Email/Linq)
- `allowed_contacts` (iMessage)
- `allowed_pubkeys` (Nostr)
@@ -199,7 +203,7 @@ allowed_sender_ids = ["123456789", "987"] # optional; "*" allowed
[channels_config.telegram]
bot_token = "123456:telegram-token"
allowed_users = ["*"]
-stream_mode = "off" # optional: off | partial
+stream_mode = "off" # optional: off | partial | on
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
@@ -215,6 +219,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.
+- `stream_mode = "on"` uses Telegram's native `sendMessageDraft` flow for private chats. Non-private chats, or runtime `sendMessageDraft` API failures, automatically fall back to `partial`.
### 4.2 Discord
@@ -541,7 +546,29 @@ Notes:
allowed_contacts = ["*"]
```
-### 4.18 ACP
+### 4.20 WATI
+
+```toml
+[channels_config.wati]
+api_token = "wati-api-token"
+api_url = "https://live-mt-server.wati.io" # optional
+webhook_secret = "required-shared-secret"
+tenant_id = "tenant-id" # optional
+allowed_numbers = ["*"] # optional, "*" = allow all
+```
+
+Notes:
+
+- Inbound webhook endpoint: `POST /wati`.
+- WATI webhook auth is fail-closed:
+ - `500` when `webhook_secret` is not configured.
+ - `401` when signature/bearer auth is missing or invalid.
+- Accepted auth methods:
+ - `X-Hub-Signature-256`, `X-Wati-Signature`, or `X-Webhook-Signature` HMAC-SHA256 (`sha256=` or raw hex)
+ - `Authorization: Bearer ` fallback
+- `ZEROCLAW_WATI_WEBHOOK_SECRET` overrides `webhook_secret` when set.
+
+### 4.21 ACP
ACP (Agent Client Protocol) enables ZeroClaw to act as a client for OpenCode ACP server,
allowing remote control of OpenCode behavior through JSON-RPC 2.0 communication over stdio.
diff --git a/docs/ci-blacksmith.md b/docs/ci-blacksmith.md
new file mode 100644
index 000000000..f816892f9
--- /dev/null
+++ b/docs/ci-blacksmith.md
@@ -0,0 +1,64 @@
+# Blacksmith Production Build Pipeline
+
+This document describes the production binary build lane for ZeroClaw on Blacksmith-backed GitHub Actions runners.
+
+## Workflow
+
+- File: `.github/workflows/release-build.yml`
+- Workflow name: `Production Release Build`
+- Triggers:
+ - Push to `main`
+ - Push tags matching `v*`
+ - Manual dispatch (`workflow_dispatch`)
+
+## Runner Labels
+
+The workflow runs on the same Blacksmith self-hosted runner label-set used by the rest of CI:
+
+`[self-hosted, Linux, X64, aws-india, blacksmith-2vcpu-ubuntu-2404, hetzner]`
+
+This keeps runner routing consistent with existing CI jobs and actionlint policy.
+
+## Canonical Commands
+
+Quality gates (must pass before release build):
+
+```bash
+cargo fmt --all -- --check
+cargo clippy --locked --all-targets -- -D warnings
+cargo test --locked --verbose
+```
+
+Production build command (canonical):
+
+```bash
+cargo build --release --locked
+```
+
+## Artifact Output
+
+- Binary path: `target/release/zeroclaw`
+- Uploaded artifact name: `zeroclaw-linux-amd64`
+- Uploaded files:
+ - `artifacts/zeroclaw`
+ - `artifacts/zeroclaw.sha256`
+
+## Re-run and Debug
+
+1. Open Actions run for `Production Release Build`.
+2. Use `Re-run failed jobs` (or full rerun) from the run page.
+3. Inspect step logs in this order: `Rust quality gates` -> `Build production binary (canonical)` -> `Prepare artifact bundle`.
+4. Download `zeroclaw-linux-amd64` from the run artifacts and verify checksum:
+
+```bash
+sha256sum -c zeroclaw.sha256
+```
+
+5. Reproduce locally from repository root with the same command set:
+
+```bash
+cargo fmt --all -- --check
+cargo clippy --locked --all-targets -- -D warnings
+cargo test --locked --verbose
+cargo build --release --locked
+```
diff --git a/docs/ci-map.md b/docs/ci-map.md
index b786ab21d..bd9632c6f 100644
--- a/docs/ci-map.md
+++ b/docs/ci-map.md
@@ -61,6 +61,11 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- Noise control: excludes common test/fixture paths and test file patterns by default (`include_tests=false`)
- `.github/workflows/pub-release.yml` (`Release`)
- Purpose: build release artifacts in verification mode (manual/scheduled) and publish GitHub releases on tag push or manual publish mode
+- `.github/workflows/release-build.yml` (`Production Release Build`)
+ - Purpose: build reproducible Linux x86_64 production binaries on `main` pushes and `v*` tags using Blacksmith runners
+ - Canonical build command: `cargo build --release --locked`
+ - Quality gates: `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D warnings`, and `cargo test --locked --verbose` before release build
+ - Artifact output: `zeroclaw-linux-amd64` (`target/release/zeroclaw` + `.sha256`)
- `.github/workflows/pr-label-policy-check.yml` (`Label Policy Sanity`)
- Purpose: validate shared contributor-tier policy in `.github/label-policy.json` and ensure label workflows consume that policy
@@ -98,6 +103,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `Feature Matrix`: push on Rust + workflow paths to `dev`, merge queue, weekly schedule, manual dispatch; PRs only when `ci:full` or `ci:feature-matrix` label is applied
- `Nightly All-Features`: daily schedule and manual dispatch
- `Release`: tag push (`v*`), weekly schedule (verification-only), manual dispatch (verification or publish)
+- `Production Release Build`: push to `main`, push tags matching `v*`, manual dispatch
- `Security Audit`: push to `dev` and `main`, PRs to `dev` and `main`, weekly schedule
- `Sec Vorpal Reviewdog`: manual dispatch only
- `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change
@@ -116,18 +122,20 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
2. Docker failures on PRs: inspect `.github/workflows/pub-docker-img.yml` `pr-smoke` job.
- For tag-publish failures, inspect `ghcr-publish-contract.json` / `audit-event-ghcr-publish-contract.json`, `ghcr-vulnerability-gate.json` / `audit-event-ghcr-vulnerability-gate.json`, and Trivy artifacts from `pub-docker-img.yml`.
3. Release failures (tag/manual/scheduled): inspect `.github/workflows/pub-release.yml` and the `prepare` job outputs.
-4. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
-5. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
-6. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs.
-7. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
-8. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
-9. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
+4. Production release build failures (`main`/`v*`): inspect `.github/workflows/release-build.yml` quality-gate + build steps.
+5. Security failures: inspect `.github/workflows/sec-audit.yml` and `deny.toml`.
+6. Workflow syntax/lint failures: inspect `.github/workflows/workflow-sanity.yml`.
+7. PR intake failures: inspect `.github/workflows/pr-intake-checks.yml` sticky comment and run logs. If intake policy changed recently, trigger a fresh `pull_request_target` event (for example close/reopen PR) because `Re-run jobs` can reuse the original workflow snapshot.
+8. Label policy parity failures: inspect `.github/workflows/pr-label-policy-check.yml`.
+9. Docs failures in CI: inspect `docs-quality` job logs in `.github/workflows/ci-run.yml`.
+10. Strict delta lint failures in CI: inspect `lint-strict-delta` job logs and compare with `BASE_SHA` diff scope.
## Maintenance Rules
- Keep merge-blocking checks deterministic and reproducible (`--locked` where applicable).
- Keep merge-queue compatibility explicit by supporting `merge_group` on required workflows (`ci-run`, `sec-audit`, and `sec-codeql`).
-- Keep PRs mapped to Linear issue keys (`RMN-*`/`CDV-*`/`COM-*`) via PR intake checks.
+- Keep PRs mapped to Linear issue keys (`RMN-*`/`CDV-*`/`COM-*`) when available for traceability (recommended by PR intake checks, non-blocking).
+- Keep PR intake backfills event-driven: when intake logic changes, prefer triggering a fresh PR event over rerunning old runs so checks evaluate against the latest workflow/script snapshot.
- Keep `deny.toml` advisory ignore entries in object form with explicit reasons (enforced by `deny_policy_guard.py`).
- Keep deny ignore governance metadata current in `.github/security/deny-ignore-governance.json` (owner/reason/expiry/ticket enforced by `deny_policy_guard.py`).
- Keep gitleaks allowlist governance metadata current in `.github/security/gitleaks-allowlist-governance.json` (owner/reason/expiry/ticket enforced by `secrets_governance_guard.py`).
@@ -139,6 +147,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- Keep pre-release stage transition policy + matrix coverage + transition audit semantics current in `.github/release/prerelease-stage-gates.json`.
- Keep required check naming stable and documented in `docs/operations/required-check-mapping.md` before changing branch protection settings.
- Follow `docs/release-process.md` for verify-before-publish release cadence and tag discipline.
+- Keep production build reproducibility anchored to `cargo build --release --locked` in `.github/workflows/release-build.yml`.
- Keep merge-blocking rust quality policy aligned across `.github/workflows/ci-run.yml`, `dev/ci.sh`, and `.githooks/pre-push` (`./scripts/ci/rust_quality_gate.sh` + `./scripts/ci/rust_strict_delta_gate.sh`).
- Use `./scripts/ci/rust_strict_delta_gate.sh` (or `./dev/ci.sh lint-delta`) as the incremental strict merge gate for changed Rust lines.
- Run full strict lint audits regularly via `./scripts/ci/rust_quality_gate.sh --strict` (for example through `./dev/ci.sh lint-strict`) and track cleanup in focused PRs.
diff --git a/docs/commands-reference.md b/docs/commands-reference.md
index 4f6b6adb4..2839992ae 100644
--- a/docs/commands-reference.md
+++ b/docs/commands-reference.md
@@ -15,6 +15,7 @@ Last verified: **February 28, 2026**.
| `service` | Manage user-level OS service lifecycle |
| `doctor` | Run diagnostics and freshness checks |
| `status` | Print current configuration and system summary |
+| `update` | Check or install latest ZeroClaw release |
| `estop` | Engage/resume emergency stop levels and inspect estop state |
| `cron` | Manage scheduled tasks |
| `models` | Refresh provider model catalogs |
@@ -40,6 +41,8 @@ Last verified: **February 28, 2026**.
- `zeroclaw onboard --api-key --provider --memory `
- `zeroclaw onboard --api-key --provider --model --memory `
- `zeroclaw onboard --api-key --provider --model --memory --force`
+- `zeroclaw onboard --migrate-openclaw`
+- `zeroclaw onboard --migrate-openclaw --openclaw-source --openclaw-config `
`onboard` safety behavior:
@@ -48,6 +51,8 @@ Last verified: **February 28, 2026**.
- Provider-only update (update provider/model/API key while preserving existing channels, tunnel, memory, hooks, and other settings)
- In non-interactive environments, existing `config.toml` causes a safe refusal unless `--force` is passed.
- Use `zeroclaw onboard --channels-only` when you only need to rotate channel tokens/allowlists.
+- OpenClaw migration mode is merge-first by design: existing ZeroClaw data/config is preserved, missing fields are filled, and list-like values are union-merged with de-duplication.
+- Interactive onboarding can auto-detect `~/.openclaw` and prompt for optional merge migration even without `--migrate-openclaw`.
### `agent`
@@ -59,9 +64,11 @@ Last verified: **February 28, 2026**.
Tip:
- In interactive chat, you can ask for route changes in natural language (for example “conversation uses kimi, coding uses gpt-5.3-codex”); the assistant can persist this via tool `model_routing_config`.
+- In interactive chat, you can also ask for runtime orchestration changes in natural language (for example “disable agent teams”, “enable subagents”, “set max concurrent subagents to 24”, “use least_loaded strategy”); the assistant can persist this via `model_routing_config` action `set_orchestration`.
- In interactive chat, you can also ask to:
- switch web search provider/fallbacks (`web_search_config`)
- inspect or update domain access policy (`web_access_config`)
+ - preview/apply OpenClaw merge migration (`openclaw_migration`)
### `gateway` / `daemon`
@@ -98,6 +105,18 @@ Notes:
- `zeroclaw service status`
- `zeroclaw service uninstall`
+### `update`
+
+- `zeroclaw update --check` (check for new release, no install)
+- `zeroclaw update` (install latest release binary for current platform)
+- `zeroclaw update --force` (reinstall even if current version matches latest)
+- `zeroclaw update --instructions` (print install-method-specific guidance)
+
+Notes:
+
+- If ZeroClaw is installed via Homebrew, prefer `brew upgrade zeroclaw`.
+- `update --instructions` detects common install methods and prints the safest path.
+
### `cron`
- `zeroclaw cron list`
@@ -120,7 +139,7 @@ Notes:
- `zeroclaw models refresh --provider `
- `zeroclaw models refresh --force`
-`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`.
+`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`, `stepfun`, `glm`, `zai`, `qwen`, `volcengine` (`doubao`/`ark` aliases), `siliconflow`, and `nvidia`.
#### Live model availability test
@@ -173,6 +192,8 @@ Runtime in-chat commands while channel server is running:
- Supervised tool approvals (all non-CLI channels):
- `/approve-request ` (create pending approval request)
- `/approve-confirm ` (confirm pending request; same sender + same chat/channel only)
+ - `/approve-allow ` (approve current pending runtime execution request once; no policy persistence)
+ - `/approve-deny ` (deny current pending runtime execution request)
- `/approve-pending` (list pending requests in current sender+chat/channel scope)
- `/approve ` (direct one-step grant + persist to `autonomy.auto_approve`, compatibility path)
- `/unapprove ` (revoke + remove from `autonomy.auto_approve`)
@@ -259,11 +280,24 @@ Registry packages are installed to `~/.zeroclaw/workspace/skills//`.
Use `skills audit` to manually validate a candidate skill directory (or an installed skill by name) before sharing it.
+Workspace symlink policy:
+- Symlinked entries under `~/.zeroclaw/workspace/skills/` are blocked by default.
+- To allow shared local skill directories, set `[skills].trusted_skill_roots` in `config.toml`.
+- A symlinked skill is accepted only when its resolved canonical target is inside one of the trusted roots.
+
Skill manifests (`SKILL.toml`) support `prompts` and `[[tools]]`; both are injected into the agent system prompt at runtime, so the model can follow skill instructions without manually reading skill files.
### `migrate`
-- `zeroclaw migrate openclaw [--source ] [--dry-run]`
+- `zeroclaw migrate openclaw [--source ] [--source-config ] [--dry-run] [--no-memory] [--no-config]`
+
+`migrate openclaw` behavior:
+
+- Default mode migrates both memory and config/agents with merge-first semantics.
+- Existing ZeroClaw values are preserved; migration does not overwrite existing user content.
+- Memory migration de-duplicates repeated content during merge while keeping existing entries intact.
+- `--dry-run` prints a migration report without writing data.
+- `--no-memory` or `--no-config` scopes migration to selected modules.
### `config`
diff --git a/docs/config-reference.md b/docs/config-reference.md
index 08d175ea7..2a62f5e48 100644
--- a/docs/config-reference.md
+++ b/docs/config-reference.md
@@ -46,6 +46,7 @@ Use named profiles to map a logical provider id to a provider name/base URL and
|---|---|---|
| `name` | unset | Optional provider id override (for example `openai`, `openai-codex`) |
| `base_url` | unset | Optional OpenAI-compatible endpoint URL |
+| `auth_header` | unset | Optional auth header for `custom:` endpoints (for example `api-key` for Azure OpenAI) |
| `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) |
@@ -55,6 +56,7 @@ 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.
+- `auth_header` is only applied when the resolved provider is `custom:` and the profile `base_url` matches that custom URL.
- Secrets encryption applies to profile API keys when `secrets.encrypt = true`.
Example:
@@ -129,6 +131,8 @@ Operational note for container users:
| `max_history_messages` | `50` | Maximum conversation history messages retained per session |
| `parallel_tools` | `false` | Enable parallel tool execution within a single iteration |
| `tool_dispatcher` | `auto` | Tool dispatch strategy |
+| `allowed_tools` | `[]` | Primary-agent tool allowlist. When non-empty, only listed tools are exposed in context |
+| `denied_tools` | `[]` | Primary-agent tool denylist applied after `allowed_tools` |
| `loop_detection_no_progress_threshold` | `3` | Same tool+args producing identical output this many times triggers loop detection. `0` disables |
| `loop_detection_ping_pong_cycles` | `2` | A→B→A→B alternating pattern cycle count threshold. `0` disables |
| `loop_detection_failure_streak` | `3` | Same tool consecutive failure count threshold. `0` disables |
@@ -139,8 +143,126 @@ Notes:
- If a channel message exceeds this value, the runtime returns: `Agent exceeded maximum tool iterations ()`.
- In CLI, gateway, and channel tool loops, multiple independent tool calls are executed concurrently by default when the pending calls do not require approval gating; result order remains stable.
- `parallel_tools` applies to the `Agent::turn()` API surface. It does not gate the runtime loop used by CLI, gateway, or channel handlers.
+- `allowed_tools` / `denied_tools` are applied at startup before prompt construction. Excluded tools are omitted from system prompt context and tool specs.
+- Unknown entries in `allowed_tools` are skipped and logged at debug level.
+- If both `allowed_tools` and `denied_tools` are configured and the denylist removes all allowlisted matches, startup fails fast with a clear config error.
- **Loop detection** intervenes before `max_tool_iterations` is exhausted. On first detection the agent receives a self-correction prompt; if the loop persists the agent is stopped early. Detection is result-aware: repeated calls with *different* outputs (genuine progress) do not trigger. Set any threshold to `0` to disable that detector.
+Example:
+
+```toml
+[agent]
+allowed_tools = [
+ "delegate",
+ "subagent_spawn",
+ "subagent_list",
+ "subagent_manage",
+ "memory_recall",
+ "memory_store",
+ "task_plan",
+]
+denied_tools = ["shell", "file_write", "browser_open"]
+```
+
+## `[agent.teams]`
+
+Controls synchronous team delegation behavior (`delegate` tool).
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `true` | Enable/disable agent-team delegation runtime |
+| `auto_activate` | `true` | Allow automatic team-agent selection when `delegate.agent` is omitted or `"auto"` |
+| `max_agents` | `32` | Max active delegate profiles considered for team selection |
+| `strategy` | `adaptive` | Load-balancing strategy: `semantic`, `adaptive`, `least_loaded` |
+| `load_window_secs` | `120` | Sliding window used for recent load/failure scoring |
+| `inflight_penalty` | `8` | Score penalty per in-flight task |
+| `recent_selection_penalty` | `2` | Score penalty per recent assignment within the load window |
+| `recent_failure_penalty` | `12` | Score penalty per recent failure within the load window |
+
+Notes:
+
+- `semantic` preserves lexical/metadata matching priority.
+- `adaptive` blends semantic signals with runtime load and recent outcomes (default).
+- `least_loaded` prioritizes healthy least-loaded agents before semantic tie-breakers.
+- `max_agents` has no hard-coded upper cap in tooling; use any positive integer that fits the platform.
+- `max_agents` and `load_window_secs` must be greater than `0`.
+
+Example:
+
+```toml
+[agent.teams]
+enabled = true
+auto_activate = true
+max_agents = 48
+strategy = "adaptive"
+load_window_secs = 180
+inflight_penalty = 10
+recent_selection_penalty = 3
+recent_failure_penalty = 14
+```
+
+## `[agent.subagents]`
+
+Controls asynchronous/background delegation (`subagent_spawn`, `subagent_list`, `subagent_manage`).
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `true` | Enable/disable background sub-agent runtime |
+| `auto_activate` | `true` | Allow automatic sub-agent selection when `subagent_spawn.agent` is omitted or `"auto"` |
+| `max_concurrent` | `10` | Max number of concurrently running background sub-agents |
+| `strategy` | `adaptive` | Load-balancing strategy: `semantic`, `adaptive`, `least_loaded` |
+| `load_window_secs` | `180` | Sliding window used for recent load/failure scoring |
+| `inflight_penalty` | `10` | Score penalty per in-flight task |
+| `recent_selection_penalty` | `3` | Score penalty per recent assignment within the load window |
+| `recent_failure_penalty` | `16` | Score penalty per recent failure within the load window |
+| `queue_wait_ms` | `15000` | Wait duration for free concurrency slot before failing (`0` = fail-fast) |
+| `queue_poll_ms` | `200` | Poll interval while waiting for a slot |
+
+Notes:
+
+- `max_concurrent` has no hard-coded upper cap in tooling; use any positive integer that fits the platform.
+- `max_concurrent`, `load_window_secs`, and `queue_poll_ms` must be greater than `0`.
+- `queue_wait_ms = 0` is valid and forces immediate failure when at capacity.
+
+Example:
+
+```toml
+[agent.subagents]
+enabled = true
+auto_activate = true
+max_concurrent = 24
+strategy = "least_loaded"
+load_window_secs = 240
+inflight_penalty = 12
+recent_selection_penalty = 4
+recent_failure_penalty = 18
+queue_wait_ms = 30000
+queue_poll_ms = 250
+```
+
+## Runtime Orchestration Updates (Natural Language + Tool)
+
+You can update the orchestration controls in interactive chat with natural language requests (for example: "disable subagents", "set subagents max concurrent to 20", "switch team strategy to least-loaded").
+
+The runtime persists these updates via `model_routing_config` (`action = "set_orchestration"`), and delegation tools hot-apply them without requiring a process restart.
+
+Example tool payload:
+
+```json
+{
+ "action": "set_orchestration",
+ "teams_enabled": true,
+ "teams_strategy": "adaptive",
+ "max_team_agents": 64,
+ "subagents_enabled": true,
+ "subagents_auto_activate": true,
+ "max_concurrent_subagents": 32,
+ "subagents_strategy": "least_loaded",
+ "subagents_queue_wait_ms": 15000,
+ "subagents_queue_poll_ms": 200
+}
+```
+
## `[security.otp]`
| Key | Default | Purpose |
@@ -278,6 +400,18 @@ Environment overrides:
- `ZEROCLAW_URL_ACCESS_DOMAIN_BLOCKLIST` / `URL_ACCESS_DOMAIN_BLOCKLIST` (comma-separated)
- `ZEROCLAW_URL_ACCESS_APPROVED_DOMAINS` / `URL_ACCESS_APPROVED_DOMAINS` (comma-separated)
+## `[security]`
+
+| Key | Default | Purpose |
+|---|---|---|
+| `canary_tokens` | `true` | Inject per-turn canary token into system prompt and block responses that echo it |
+
+Notes:
+
+- Canary tokens are generated per turn and are redacted from runtime traces.
+- This guard is additive to `security.outbound_leak_guard`: canary catches prompt-context leakage, while outbound leak guard catches credential-like material.
+- Set `canary_tokens = false` to disable this layer.
+
## `[security.syscall_anomaly]`
| Key | Default | Purpose |
@@ -536,6 +670,7 @@ Notes:
|---|---|---|
| `open_skills_enabled` | `false` | Opt-in loading/sync of community `open-skills` repository |
| `open_skills_dir` | unset | Optional local path for `open-skills` (defaults to `$HOME/open-skills` when enabled) |
+| `trusted_skill_roots` | `[]` | Allowlist of directory roots for symlink targets in `workspace/skills/*` |
| `prompt_injection_mode` | `full` | Skill prompt verbosity: `full` (inline instructions/tools) or `compact` (name/description/location only) |
| `clawhub_token` | unset | Optional Bearer token for authenticated ClawhHub skill downloads |
@@ -548,7 +683,8 @@ Notes:
- `ZEROCLAW_SKILLS_PROMPT_MODE` accepts `full` or `compact`.
- Precedence for enable flag: `ZEROCLAW_OPEN_SKILLS_ENABLED` → `skills.open_skills_enabled` in `config.toml` → default `false`.
- `prompt_injection_mode = "compact"` is recommended on low-context local models to reduce startup prompt size while keeping skill files available on demand.
-- Skill loading and `zeroclaw skills install` both apply a static security audit. Skills that contain symlinks, script-like files, high-risk shell payload snippets, or unsafe markdown link traversal are rejected.
+- Symlinked workspace skills are blocked by default. Set `trusted_skill_roots` to allow local shared-skill directories after explicit trust review.
+- `zeroclaw skills install` and `zeroclaw skills audit` apply a static security audit. Skills that contain script-like files, high-risk shell payload snippets, or unsafe markdown link traversal are rejected.
- `clawhub_token` is sent as `Authorization: Bearer ` when downloading from ClawhHub. Obtain a token from [https://clawhub.ai](https://clawhub.ai) after signing in. Required if the API returns 429 (rate-limited) or 401 (unauthorized) for anonymous requests.
**ClawhHub token example:**
@@ -620,6 +756,11 @@ Notes:
- Remote URL only when `allow_remote_fetch = true`
- Allowed MIME types: `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/bmp`.
- When the active provider does not support vision, requests fail with a structured capability error (`capability=vision`) instead of silently dropping images.
+- In `proxy.scope = "services"` mode, remote image fetch uses service-key routing. For best compatibility include relevant selectors/keys such as:
+ - `channel.qq` (QQ media hosts like `multimedia.nt.qq.com.cn`)
+ - `tool.multimodal` (dedicated multimodal fetch path)
+ - `tool.http_request` (compatibility fallback path)
+ - `provider.*` or the active provider key (for example `provider.openai`)
## `[browser]`
@@ -710,8 +851,8 @@ When using `credential_profile`, do not also set the same header key in `args.he
| Key | Default | Purpose |
|---|---|---|
| `enabled` | `false` | Enable `web_fetch` for page-to-text extraction |
-| `provider` | `fast_html2md` | Fetch/render backend: `fast_html2md`, `nanohtml2text`, `firecrawl` |
-| `api_key` | unset | API key for provider backends that require it (e.g. `firecrawl`) |
+| `provider` | `fast_html2md` | Fetch/render backend: `fast_html2md`, `nanohtml2text`, `firecrawl`, `tavily` |
+| `api_key` | unset | API key for provider backends that require it (e.g. `firecrawl`, `tavily`) |
| `api_url` | unset | Optional API URL override (self-hosted/alternate endpoint) |
| `allowed_domains` | `["*"]` | Domain allowlist (`"*"` allows all public domains) |
| `blocked_domains` | `[]` | Denylist applied before allowlist |
@@ -855,6 +996,7 @@ Environment overrides:
| `level` | `supervised` | `read_only`, `supervised`, or `full` |
| `workspace_only` | `true` | reject absolute path inputs unless explicitly disabled |
| `allowed_commands` | _required for shell execution_ | allowlist of executable names, explicit executable paths, or `"*"` |
+| `command_context_rules` | `[]` | per-command context-aware allow/deny/require-approval rules (domain/path constraints, optional high-risk override) |
| `forbidden_paths` | built-in protected list | explicit path denylist (system paths + sensitive dotdirs by default) |
| `allowed_roots` | `[]` | additional roots allowed outside workspace after canonicalization |
| `max_actions_per_hour` | `20` | per-policy action budget |
@@ -865,7 +1007,7 @@ Environment overrides:
| `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 |
+| `non_cli_excluded_tools` | built-in denylist (includes `shell`, `process`, `file_write`, ...) | tools hidden from non-CLI channel tool specs |
| `non_cli_approval_approvers` | `[]` | optional allowlist for who can run non-CLI approval-management commands |
| `non_cli_natural_language_approval_mode` | `direct` | natural-language behavior for approval-management commands (`direct`, `request_confirm`, `disabled`) |
| `non_cli_natural_language_approval_mode_by_channel` | `{}` | per-channel override map for natural-language approval mode |
@@ -876,6 +1018,11 @@ 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).
+- `command_context_rules` can narrow or override `allowed_commands` for matching commands:
+ - `action = "allow"` rules are restrictive when present for a command: at least one allow rule must match.
+ - `action = "deny"` rules explicitly block matching contexts.
+ - `action = "require_approval"` forces explicit approval (`approved=true`) in supervised mode for matching segments, even if `shell` is in `auto_approve`.
+ - `allow_high_risk = true` allows a matching high-risk command to pass the hard block, but supervised mode still requires `approved=true`.
- `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.
@@ -885,6 +1032,10 @@ Notes:
- One-step flow: `/approve `.
- Two-step flow: `/approve-request ` then `/approve-confirm ` (same sender + same chat/channel).
Both paths write to `autonomy.auto_approve` and remove the tool from `autonomy.always_ask`.
+- For pending runtime execution prompts (including Telegram inline approval buttons), use:
+ - `/approve-allow ` to approve only the current pending request.
+ - `/approve-deny ` to reject the current pending request.
+ This path does not modify `autonomy.auto_approve` or `autonomy.always_ask`.
- `non_cli_natural_language_approval_mode` controls how strict natural-language approval intents are:
- `direct` (default): natural-language approval grants immediately (private-chat friendly).
- `request_confirm`: natural-language approval creates a pending request that needs explicit confirm.
@@ -897,6 +1048,7 @@ Notes:
- `telegram:alice` allows only that channel+sender pair.
- `telegram:*` allows any sender on Telegram.
- `*:alice` allows `alice` on any channel.
+- By default, `process` is excluded on non-CLI channels alongside `shell`. To opt in intentionally, remove `"process"` from `[autonomy].non_cli_excluded_tools` in `config.toml`.
- Use `/unapprove ` to remove persisted approval from `autonomy.auto_approve`.
- `/approve-pending` lists pending requests for the current sender+chat/channel scope.
- If a tool remains unavailable after approval, check `autonomy.non_cli_excluded_tools` (runtime `/approvals` shows this list). Channel runtime reloads this list from `config.toml` automatically.
@@ -906,6 +1058,22 @@ Notes:
workspace_only = false
forbidden_paths = ["/etc", "/root", "/proc", "/sys", "~/.ssh", "~/.gnupg", "~/.aws"]
allowed_roots = ["~/Desktop/projects", "/opt/shared-repo"]
+
+[[autonomy.command_context_rules]]
+command = "curl"
+action = "allow"
+allowed_domains = ["api.github.com", "*.example.internal"]
+allow_high_risk = true
+
+[[autonomy.command_context_rules]]
+command = "rm"
+action = "allow"
+allowed_path_prefixes = ["/tmp"]
+allow_high_risk = true
+
+[[autonomy.command_context_rules]]
+command = "rm"
+action = "require_approval"
```
## `[memory]`
@@ -1063,10 +1231,70 @@ Notes:
- `mode = "all_messages"` or `mode = "mention_only"`
- `allowed_sender_ids = ["..."]` to bypass mention gating in groups
- `allowed_users` allowlist checks still run first
+- Telegram/Discord/Lark/Feishu ACK emoji reactions are configurable under
+ `[channels_config.ack_reaction.]` with switchable enable state,
+ custom emoji pools, and conditional rules.
- Legacy `mention_only` flags (Telegram/Discord/Mattermost/Lark) remain supported as fallback only.
If `group_reply.mode` is set, it takes precedence over legacy `mention_only`.
- While `zeroclaw channel start` is running, updates to `default_provider`, `default_model`, `default_temperature`, `api_key`, `api_url`, and `reliability.*` are hot-applied from `config.toml` on the next inbound message.
+### `[channels_config.ack_reaction.]`
+
+Per-channel ACK reaction policy (``: `telegram`, `discord`, `lark`, `feishu`).
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `true` | Master switch for ACK reactions on this channel |
+| `strategy` | `random` | Pool selection strategy: `random` or `first` |
+| `sample_rate` | `1.0` | Probabilistic gate in `[0.0, 1.0]` for channel fallback ACKs |
+| `emojis` | `[]` | Channel-level custom fallback pool (uses built-in pool when empty) |
+| `rules` | `[]` | Ordered conditional rules; first matching rule can react or suppress |
+
+Rule object fields (`[[channels_config.ack_reaction..rules]]`):
+
+| Key | Default | Purpose |
+|---|---|---|
+| `enabled` | `true` | Enable/disable this single rule |
+| `contains_any` | `[]` | Match when message contains any keyword (case-insensitive) |
+| `contains_all` | `[]` | Match when message contains all keywords (case-insensitive) |
+| `contains_none` | `[]` | Match only when message contains none of these keywords |
+| `regex_any` | `[]` | Match when any regex pattern matches |
+| `regex_all` | `[]` | Match only when all regex patterns match |
+| `regex_none` | `[]` | Match only when none of these regex patterns match |
+| `sender_ids` | `[]` | Match only these sender IDs (`"*"` matches all) |
+| `chat_ids` | `[]` | Match only these chat/channel IDs (`"*"` matches all) |
+| `chat_types` | `[]` | Restrict to `group` and/or `direct` |
+| `locale_any` | `[]` | Restrict by locale tag (prefix supported, e.g. `zh`) |
+| `action` | `react` | `react` to emit ACK, `suppress` to force no ACK when matched |
+| `sample_rate` | unset | Optional rule-level gate in `[0.0, 1.0]` (overrides channel `sample_rate`) |
+| `strategy` | unset | Optional per-rule strategy override |
+| `emojis` | `[]` | Emoji pool used when this rule matches |
+
+Example:
+
+```toml
+[channels_config.ack_reaction.telegram]
+enabled = true
+strategy = "random"
+sample_rate = 1.0
+emojis = ["✅", "👌", "🔥"]
+
+[[channels_config.ack_reaction.telegram.rules]]
+contains_any = ["deploy", "release"]
+contains_none = ["dry-run"]
+regex_none = ["panic|fatal"]
+chat_ids = ["-100200300"]
+chat_types = ["group"]
+strategy = "first"
+sample_rate = 0.9
+emojis = ["🚀"]
+
+[[channels_config.ack_reaction.telegram.rules]]
+contains_any = ["error", "failed"]
+action = "suppress"
+sample_rate = 1.0
+```
+
### `[channels_config.nostr]`
| Key | Default | Purpose |
diff --git a/docs/docs-inventory.md b/docs/docs-inventory.md
index b3b1ae175..aae833215 100644
--- a/docs/docs-inventory.md
+++ b/docs/docs-inventory.md
@@ -2,7 +2,7 @@
This inventory classifies documentation by intent and canonical location.
-Last reviewed: **February 28, 2026**.
+Last reviewed: **March 1, 2026**.
## Classification Legend
@@ -125,6 +125,8 @@ 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/project/f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md` | Snapshot (F1-3 lifecycle state machine RFI) |
+| `docs/project/q0-3-stop-reason-state-machine-rfi-2026-03-01.md` | Snapshot (Q0-3 stop-reason/continuation RFI) |
| `docs/i18n-gap-backlog.md` | Snapshot (i18n depth gap tracking) |
## Maintenance Contract
diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md
index da808e2ef..7a1b1001e 100644
--- a/docs/getting-started/README.md
+++ b/docs/getting-started/README.md
@@ -18,7 +18,7 @@ For first-time setup and quick orientation.
| I want guided prompts | `zeroclaw onboard --interactive` |
| Config exists, just fix channels | `zeroclaw onboard --channels-only` |
| Config exists, I intentionally want full overwrite | `zeroclaw onboard --force` |
-| Using subscription auth | See [Subscription Auth](../../README.md#subscription-auth-openai-codex--claude-code) |
+| Using OpenAI Codex subscription auth | See [OpenAI Codex OAuth Quick Setup](#openai-codex-oauth-quick-setup) |
## Onboarding and Validation
@@ -28,6 +28,50 @@ For first-time setup and quick orientation.
- Ollama cloud models (`:cloud`) require a remote `api_url` and API key (for example `api_url = "https://ollama.com"`).
- Validate environment: `zeroclaw status` + `zeroclaw doctor`
+## OpenAI Codex OAuth Quick Setup
+
+Use this path when you want `openai-codex` with subscription OAuth credentials (no API key required).
+
+1. Authenticate:
+
+```bash
+zeroclaw auth login --provider openai-codex
+```
+
+2. Verify auth material is loaded:
+
+```bash
+zeroclaw auth status --provider openai-codex
+```
+
+3. Set provider/model defaults:
+
+```toml
+default_provider = "openai-codex"
+default_model = "gpt-5.3-codex"
+default_temperature = 0.2
+
+[provider]
+transport = "auto"
+reasoning_level = "high"
+```
+
+4. Optional stable fallback model (if your account/region does not currently expose `gpt-5.3-codex`):
+
+```toml
+default_model = "gpt-5.2-codex"
+```
+
+5. Start chat:
+
+```bash
+zeroclaw chat
+```
+
+Notes:
+- You do not need to define a custom `[model_providers."openai-codex"]` block for normal OAuth usage.
+- If you see raw `` tags in output, first verify you are on the built-in `openai-codex` provider path above and not a custom OpenAI-compatible provider override.
+
## Next
- Runtime operations: [../operations/README.md](../operations/README.md)
diff --git a/docs/getting-started/macos-update-uninstall.md b/docs/getting-started/macos-update-uninstall.md
index 944cd4ce3..f08bc5042 100644
--- a/docs/getting-started/macos-update-uninstall.md
+++ b/docs/getting-started/macos-update-uninstall.md
@@ -20,6 +20,13 @@ If both exist, your shell `PATH` order decides which one runs.
## 2) Update on macOS
+Quick way to get install-method-specific guidance:
+
+```bash
+zeroclaw update --instructions
+zeroclaw update --check
+```
+
### A) Homebrew install
```bash
@@ -54,6 +61,13 @@ Re-run your download/install flow with the latest release asset, then verify:
zeroclaw --version
```
+You can also use the built-in updater for manual/local installs:
+
+```bash
+zeroclaw update
+zeroclaw --version
+```
+
## 3) Uninstall on macOS
### A) Stop and remove background service first
diff --git a/docs/i18n/el/arduino-uno-q-setup.md b/docs/i18n/el/arduino-uno-q-setup.md
index 97ca2b1da..fbaa4854d 100644
--- a/docs/i18n/el/arduino-uno-q-setup.md
+++ b/docs/i18n/el/arduino-uno-q-setup.md
@@ -66,7 +66,7 @@ ssh arduino@
4. **Λήψη και Μεταγλώττιση**:
```bash
- git clone https://github.com/theonlyhennygod/zeroclaw.git
+ git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
cargo build --release --features hardware
```
diff --git a/docs/i18n/el/commands-reference.md b/docs/i18n/el/commands-reference.md
index 5fc8e9609..7a4649e82 100644
--- a/docs/i18n/el/commands-reference.md
+++ b/docs/i18n/el/commands-reference.md
@@ -44,6 +44,15 @@
- `zeroclaw daemon [--host ] [--port ]`
- Το `--new-pairing` καθαρίζει όλα τα αποθηκευμένα paired tokens και δημιουργεί νέο pairing code κατά την εκκίνηση του gateway.
+### 2.2 OpenClaw Migration Surface
+
+- `zeroclaw onboard --migrate-openclaw`
+- `zeroclaw onboard --migrate-openclaw --openclaw-source --openclaw-config `
+- `zeroclaw migrate openclaw --dry-run`
+- `zeroclaw migrate openclaw`
+
+Σημείωση: στο agent runtime υπάρχει επίσης το εργαλείο `openclaw_migration` για controlled preview/apply migration flows.
+
### 3. `cron` (Προγραμματισμός Εργασιών)
Δυνατότητα αυτοματισμού εντολών:
diff --git a/docs/i18n/el/config-reference.md b/docs/i18n/el/config-reference.md
index be7fa77fd..f3526bc2a 100644
--- a/docs/i18n/el/config-reference.md
+++ b/docs/i18n/el/config-reference.md
@@ -68,4 +68,13 @@ allowed_users = ["το-όνομά-σας"] # Ποιοι επιτρέπεται
- Αν αλλάξετε το αρχείο `config.toml`, πρέπει να κάνετε επανεκκίνηση το ZeroClaw για να δει τις αλλαγές.
- Χρησιμοποιήστε την εντολή `zeroclaw doctor` για να βεβαιωθείτε ότι οι ρυθμίσεις σας είναι σωστές.
+
+## Ενημέρωση (2026-03-03)
+
+- Στην ενότητα `[agent]` προστέθηκαν τα `allowed_tools` και `denied_tools`.
+ - Αν το `allowed_tools` δεν είναι κενό, ο primary agent βλέπει μόνο τα εργαλεία της λίστας.
+ - Το `denied_tools` εφαρμόζεται μετά το allowlist και αφαιρεί επιπλέον εργαλεία.
+- Άγνωστες τιμές στο `allowed_tools` αγνοούνται (με debug log) και δεν μπλοκάρουν την εκκίνηση.
+- Αν `allowed_tools` και `denied_tools` καταλήξουν να αφαιρέσουν όλα τα εκτελέσιμα εργαλεία, η εκκίνηση αποτυγχάνει άμεσα με σαφές μήνυμα ρύθμισης.
+- Για πλήρη πίνακα πεδίων και παράδειγμα, δείτε το αγγλικό `config-reference.md` στην ενότητα `[agent]`.
- Μην μοιράζεστε ποτέ το αρχείο `config.toml` με άλλους, καθώς περιέχει τα μυστικά κλειδιά σας (tokens).
diff --git a/docs/i18n/es/README.md b/docs/i18n/es/README.md
new file mode 100644
index 000000000..23e72781d
--- /dev/null
+++ b/docs/i18n/es/README.md
@@ -0,0 +1,127 @@
+
+
+
+
+ZeroClaw 🦀
+
+
+ Sobrecarga cero. Compromiso cero. 100% Rust. 100% Agnóstico.
+ ⚡️ Funciona en cualquier hardware con <5MB RAM: ¡99% menos memoria que OpenClaw y 98% más económico que un Mac mini!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Desarrollado por estudiantes y miembros de las comunidades de Harvard, MIT y Sundai.Club.
+
+
+
+ 🌐 Idiomas: English · 简体中文 · Español · Português · Italiano · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά
+
+
+
+ Framework rápido, pequeño y totalmente autónomo
+ Despliega en cualquier lugar. Intercambia cualquier cosa.
+
+
+
+ ZeroClaw es el framework de runtime para flujos de trabajo agents — infraestructura que abstrae modelos, herramientas, memoria y ejecución para que los agentes puedan construirse una vez y ejecutarse en cualquier lugar.
+
+
+Arquitectura basada en traits · runtime seguro por defecto · proveedor/canal/herramienta intercambiable · todo conectable
+
+### ✨ Características
+
+- 🏎️ **Runtime Ligero por Defecto:** Los flujos de trabajo comunes de CLI y estado se ejecutan en una envoltura de memoria de pocos megabytes en builds de release.
+- 💰 **Despliegue Económico:** Diseñado para placas de bajo costo e instancias cloud pequeñas sin dependencias de runtime pesadas.
+- ⚡ **Arranques en Frío Rápidos:** El runtime Rust de binario único mantiene el inicio de comandos y daemon casi instantáneo para operaciones diarias.
+- 🌍 **Arquitectura Portátil:** Un flujo de trabajo binary-first a través de ARM, x86 y RISC-V con proveedores/canales/herramientas intercambiables.
+- 🔍 **Fase de Investigación:** Recopilación proactiva de información a través de herramientas antes de la generación de respuestas — reduce alucinaciones verificando hechos primero.
+
+### Por qué los equipos eligen ZeroClaw
+
+- **Ligero por defecto:** binario Rust pequeño, inicio rápido, huella de memoria baja.
+- **Seguro por diseño:** emparejamiento, sandboxing estricto, listas de permitidos explícitas, alcance de workspace.
+- **Totalmente intercambiable:** los sistemas principales son traits (proveedores, canales, herramientas, memoria, túneles).
+- **Sin lock-in:** soporte de proveedor compatible con OpenAI + endpoints personalizados conectables.
+
+## Inicio Rápido
+
+### Opción 1: Homebrew (macOS/Linuxbrew)
+
+```bash
+brew install zeroclaw
+```
+
+### Opción 2: Clonar + Bootstrap
+
+```bash
+git clone https://github.com/zeroclaw-labs/zeroclaw.git
+cd zeroclaw
+./bootstrap.sh
+```
+
+> **Nota:** Las builds desde fuente requieren ~2GB RAM y ~6GB disco. Para sistemas con recursos limitados, usa `./bootstrap.sh --prefer-prebuilt` para descargar un binario pre-compilado.
+
+### Opción 3: Cargo Install
+
+```bash
+cargo install zeroclaw
+```
+
+### Primera Ejecución
+
+```bash
+# Iniciar el gateway (sirve el API/UI del Dashboard Web)
+zeroclaw gateway
+
+# Abrir la URL del dashboard mostrada en los logs de inicio
+# (por defecto: http://127.0.0.1:3000/)
+
+# O chatear directamente
+zeroclaw chat "¡Hola!"
+```
+
+Para opciones de configuración detalladas, consulta [docs/one-click-bootstrap.md](../../../docs/one-click-bootstrap.md).
+
+---
+
+## ⚠️ Repositorio Oficial y Advertencia de Suplantación
+
+**Este es el único repositorio oficial de ZeroClaw:**
+
+> https://github.com/zeroclaw-labs/zeroclaw
+
+Cualquier otro repositorio, organización, dominio o paquete que afirme ser "ZeroClaw" o implique afiliación con ZeroClaw Labs **no está autorizado y no está afiliado con este proyecto**.
+
+Si encuentras suplantación o uso indebido de marca, por favor [abre un issue](https://github.com/zeroclaw-labs/zeroclaw/issues).
+
+---
+
+## Licencia
+
+ZeroClaw tiene doble licencia para máxima apertura y protección de colaboradores:
+
+| Licencia | Caso de uso |
+|---|---|
+| [MIT](../../../LICENSE-MIT) | Open-source, investigación, académico, uso personal |
+| [Apache 2.0](../../../LICENSE-APACHE) | Protección de patentes, institucional, despliegue comercial |
+
+Puedes elegir cualquiera de las dos licencias. **Los colaboradores otorgan automáticamente derechos bajo ambas** — consulta [CLA.md](../../../CLA.md) para el acuerdo completo de colaborador.
+
+## Contribuir
+
+Consulta [CONTRIBUTING.md](../../../CONTRIBUTING.md) y [CLA.md](../../../CLA.md). Implementa un trait, envía un PR.
+
+---
+
+**ZeroClaw** — Sobrecarga cero. Compromiso cero. Despliega en cualquier lugar. Intercambia cualquier cosa. 🦀
diff --git a/docs/i18n/fr/commands-reference.md b/docs/i18n/fr/commands-reference.md
index bea09eb6f..23a09a608 100644
--- a/docs/i18n/fr/commands-reference.md
+++ b/docs/i18n/fr/commands-reference.md
@@ -20,3 +20,4 @@ Source anglaise:
## Mise à jour récente
- `zeroclaw gateway` prend en charge `--new-pairing` pour effacer les tokens appairés et générer un nouveau code d'appairage.
+- Le guide anglais inclut désormais les surfaces de migration OpenClaw: `zeroclaw onboard --migrate-openclaw`, `zeroclaw migrate openclaw` et l'outil agent `openclaw_migration` (traduction complète en cours).
diff --git a/docs/i18n/fr/config-reference.md b/docs/i18n/fr/config-reference.md
index 43672a73f..9db9fd721 100644
--- a/docs/i18n/fr/config-reference.md
+++ b/docs/i18n/fr/config-reference.md
@@ -21,3 +21,8 @@ Source anglaise:
- Ajout de `provider.reasoning_level` (OpenAI Codex `/responses`). Voir la source anglaise pour les détails.
- Valeur par défaut de `agent.max_tool_iterations` augmentée à `20` (fallback sûr si `0`).
+- Ajout de `agent.allowed_tools` et `agent.denied_tools` pour filtrer les outils visibles par l'agent principal.
+ - `allowed_tools` non vide: seuls les outils listés sont exposés.
+ - `denied_tools`: retrait supplémentaire appliqué après `allowed_tools`.
+- Les entrées inconnues dans `allowed_tools` sont ignorées (log debug), sans échec de démarrage.
+- Si `allowed_tools` + `denied_tools` suppriment tous les outils exécutables, le démarrage échoue immédiatement avec une erreur de configuration claire.
diff --git a/docs/i18n/fr/providers-reference.md b/docs/i18n/fr/providers-reference.md
index 7f3a4f8ef..6eaa7252b 100644
--- a/docs/i18n/fr/providers-reference.md
+++ b/docs/i18n/fr/providers-reference.md
@@ -20,3 +20,21 @@ Source anglaise:
## Notes de mise à jour
- Ajout d'un réglage `provider.reasoning_level` pour le niveau de raisonnement OpenAI Codex. Voir la source anglaise pour les détails.
+- 2026-03-01: ajout de la prise en charge du provider StepFun (`stepfun`, alias `step`, `step-ai`, `step_ai`).
+
+## StepFun (Résumé)
+
+- Provider ID: `stepfun`
+- Aliases: `step`, `step-ai`, `step_ai`
+- Base API URL: `https://api.stepfun.com/v1`
+- Endpoints: `POST /v1/chat/completions`, `GET /v1/models`
+- Auth env var: `STEP_API_KEY` (fallback: `STEPFUN_API_KEY`)
+- Modèle par défaut: `step-3.5-flash`
+
+Validation rapide:
+
+```bash
+export STEP_API_KEY="your-stepfun-api-key"
+zeroclaw models refresh --provider stepfun
+zeroclaw agent --provider stepfun --model step-3.5-flash -m "ping"
+```
diff --git a/docs/i18n/it/README.md b/docs/i18n/it/README.md
new file mode 100644
index 000000000..e5cea0973
--- /dev/null
+++ b/docs/i18n/it/README.md
@@ -0,0 +1,141 @@
+
+
+
+
+ZeroClaw 🦀
+
+
+ Zero overhead. Zero compromesso. 100% Rust. 100% Agnostico.
+ ⚡️ Funziona su qualsiasi hardware con <5MB RAM: 99% meno memoria di OpenClaw e 98% più economico di un Mac mini!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Sviluppato da studenti e membri delle comunità Harvard, MIT e Sundai.Club.
+
+
+
+ 🌐 Lingue: English · 简体中文 · Español · Português · Italiano · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά
+
+
+
+ Framework veloce, piccolo e completamente autonomo
+ Distribuisci ovunque. Scambia qualsiasi cosa.
+
+
+
+ ZeroClaw è il framework runtime per workflow agentici — infrastruttura che astrae modelli, strumenti, memoria ed esecuzione così gli agenti possono essere costruiti una volta ed eseguiti ovunque.
+
+
+Architettura basata su trait · runtime sicuro per impostazione predefinita · provider/canale/strumento scambiabile · tutto collegabile
+
+### ✨ Caratteristiche
+
+- 🏎️ **Runtime Leggero per Impostazione Predefinita:** I comuni workflow CLI e di stato vengono eseguiti in un envelope di memoria di pochi megabyte nelle build di release.
+- 💰 **Distribuzione Economica:** Progettato per schede economiche e piccole istanze cloud senza dipendenze di runtime pesanti.
+- ⚡ **Avvii a Freddo Rapidi:** Il runtime Rust a singolo binario mantiene l'avvio di comandi e daemon quasi istantaneo per le operazioni quotidiane.
+- 🌍 **Architettura Portatile:** Un workflow binary-first attraverso ARM, x86 e RISC-V con provider/canali/strumenti scambiabili.
+- 🔍 **Fase di Ricerca:** Raccolta proattiva di informazioni attraverso gli strumenti prima della generazione della risposta — riduce le allucinazioni verificando prima i fatti.
+
+### Perché i team scelgono ZeroClaw
+
+- **Leggero per impostazione predefinita:** binario Rust piccolo, avvio rapido, footprint di memoria basso.
+- **Sicuro per design:** pairing, sandboxing rigoroso, liste di permessi esplicite, scope del workspace.
+- **Completamente scambiabile:** i sistemi core sono trait (provider, canali, strumenti, memoria, tunnel).
+- **Nessun lock-in:** supporto provider compatibile con OpenAI + endpoint personalizzati collegabili.
+
+## Avvio Rapido
+
+### Opzione 1: Homebrew (macOS/Linuxbrew)
+
+```bash
+brew install zeroclaw
+```
+
+### Opzione 2: Clona + Bootstrap
+
+```bash
+git clone https://github.com/zeroclaw-labs/zeroclaw.git
+cd zeroclaw
+./bootstrap.sh
+```
+
+> **Nota:** Le build da sorgente richiedono ~2GB RAM e ~6GB disco. Per sistemi con risorse limitate, usa `./bootstrap.sh --prefer-prebuilt` per scaricare un binario precompilato.
+
+### Opzione 3: Cargo Install
+
+```bash
+cargo install zeroclaw
+```
+
+### Prima Esecuzione
+
+```bash
+# Avvia il gateway (serve l'API/UI della Dashboard Web)
+zeroclaw gateway
+
+# Apri l'URL del dashboard mostrata nei log di avvio
+# (default: http://127.0.0.1:3000/)
+
+# O chatta direttamente
+zeroclaw chat "Ciao!"
+```
+
+Per opzioni di configurazione dettagliate, consulta [docs/one-click-bootstrap.md](../../../docs/one-click-bootstrap.md).
+
+---
+
+## ⚠️ Repository Ufficiale e Avviso di Impersonazione
+
+**Questo è l'unico repository ufficiale di ZeroClaw:**
+
+> https://github.com/zeroclaw-labs/zeroclaw
+
+Qualsiasi altro repository, organizzazione, dominio o pacchetto che affermi di essere "ZeroClaw" o implichi affiliazione con ZeroClaw Labs **non è autorizzato e non è affiliato con questo progetto**.
+
+Se incontri impersonazione o uso improprio del marchio, per favore [apri una issue](https://github.com/zeroclaw-labs/zeroclaw/issues).
+
+---
+
+## Licenza
+
+ZeroClaw è con doppia licenza per massima apertura e protezione dei contributori:
+
+| Licenza | Caso d'uso |
+|---|---|
+| [MIT](../../../LICENSE-MIT) | Open-source, ricerca, accademico, uso personale |
+| [Apache 2.0](../../../LICENSE-APACHE) | Protezione brevetti, istituzionale, distribuzione commerciale |
+
+Puoi scegliere qualsiasi licenza. **I contributori concedono automaticamente diritti sotto entrambe** — consulta [CLA.md](../../../CLA.md) per l'accordo completo dei contributori.
+
+## Contribuire
+
+Consulta [CONTRIBUTING.md](../../../CONTRIBUTING.md) e [CLA.md](../../../CLA.md). Implementa un trait, invia un PR.
+
+---
+
+**ZeroClaw** — Zero overhead. Zero compromesso. Distribuisci ovunque. Scambia qualsiasi cosa. 🦀
+
+---
+
+## Star History
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/i18n/ja/commands-reference.md b/docs/i18n/ja/commands-reference.md
index 8b634ff9e..dcaf07522 100644
--- a/docs/i18n/ja/commands-reference.md
+++ b/docs/i18n/ja/commands-reference.md
@@ -20,3 +20,4 @@
## 最新更新
- `zeroclaw gateway` は `--new-pairing` をサポートし、既存のペアリングトークンを消去して新しいペアリングコードを生成できます。
+- OpenClaw 移行関連の英語原文が更新されました: `zeroclaw onboard --migrate-openclaw`、`zeroclaw migrate openclaw`、およびエージェントツール `openclaw_migration`(ローカライズ追従は継続中)。
diff --git a/docs/i18n/ja/config-reference.md b/docs/i18n/ja/config-reference.md
index a974173a3..6fbecb6e2 100644
--- a/docs/i18n/ja/config-reference.md
+++ b/docs/i18n/ja/config-reference.md
@@ -16,3 +16,12 @@
- 設定キー名は英語のまま保持します。
- 実行時挙動の定義は英語版原文を優先します。
+
+## 更新ノート(2026-03-03)
+
+- `[agent]` に `allowed_tools` / `denied_tools` が追加されました。
+ - `allowed_tools` が空でない場合、メインエージェントには許可リストのツールのみ公開されます。
+ - `denied_tools` は許可リスト適用後に追加でツールを除外します。
+- `allowed_tools` の未一致エントリは起動失敗にせず、debug ログのみ出力されます。
+- `allowed_tools` と `denied_tools` の組み合わせで実行可能ツールが 0 件になる場合は、明確な設定エラーで fail-fast します。
+- 詳細な表と例は英語版 `config-reference.md` の `[agent]` セクションを参照してください。
diff --git a/docs/i18n/ja/providers-reference.md b/docs/i18n/ja/providers-reference.md
index 78af95755..7fc2db3b9 100644
--- a/docs/i18n/ja/providers-reference.md
+++ b/docs/i18n/ja/providers-reference.md
@@ -16,3 +16,24 @@
- Provider ID と環境変数名は英語のまま保持します。
- 正式な仕様は英語版原文を優先します。
+
+## 更新ノート
+
+- 2026-03-01: StepFun provider 対応を追加(`stepfun`、alias: `step` / `step-ai` / `step_ai`)。
+
+## StepFun クイックガイド
+
+- Provider ID: `stepfun`
+- Aliases: `step`, `step-ai`, `step_ai`
+- Base API URL: `https://api.stepfun.com/v1`
+- Endpoints: `POST /v1/chat/completions`, `GET /v1/models`
+- 認証 env var: `STEP_API_KEY`(fallback: `STEPFUN_API_KEY`)
+- 既定モデル: `step-3.5-flash`
+
+クイック検証:
+
+```bash
+export STEP_API_KEY="your-stepfun-api-key"
+zeroclaw models refresh --provider stepfun
+zeroclaw agent --provider stepfun --model step-3.5-flash -m "ping"
+```
diff --git a/docs/i18n/pt/README.md b/docs/i18n/pt/README.md
new file mode 100644
index 000000000..80c40f1c0
--- /dev/null
+++ b/docs/i18n/pt/README.md
@@ -0,0 +1,141 @@
+
+
+
+
+ZeroClaw 🦀
+
+
+ Sobrecarga zero. Compromisso zero. 100% Rust. 100% Agnóstico.
+ ⚡️ Funciona em qualquer hardware com <5MB RAM: 99% menos memória que OpenClaw e 98% mais barato que um Mac mini!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+Desenvolvido por estudantes e membros das comunidades de Harvard, MIT e Sundai.Club.
+
+
+
+ 🌐 Idiomas: English · 简体中文 · Español · Português · Italiano · 日本語 · Русский · Français · Tiếng Việt · Ελληνικά
+
+
+
+ Framework rápido, pequeno e totalmente autônomo
+ Implante em qualquer lugar. Troque qualquer coisa.
+
+
+
+ ZeroClaw é o framework de runtime para fluxos de trabalho agentes — infraestrutura que abstrai modelos, ferramentas, memória e execução para que agentes possam ser construídos uma vez e executados em qualquer lugar.
+
+
+Arquitetura baseada em traits · runtime seguro por padrão · provedor/canal/ferramenta trocável · tudo conectável
+
+### ✨ Características
+
+- 🏎️ **Runtime Enxuto por Padrão:** Fluxos de trabalho comuns de CLI e status rodam em um envelope de memória de poucos megabytes em builds de release.
+- 💰 **Implantação Econômica:** Projetado para placas de baixo custo e instâncias cloud pequenas sem dependências de runtime pesadas.
+- ⚡ **Inícios a Frio Rápidos:** Runtime Rust de binário único mantém inicialização de comandos e daemon quase instantânea para operações diárias.
+- 🌍 **Arquitetura Portátil:** Um fluxo de trabalho binary-first através de ARM, x86 e RISC-V com provedores/canais/ferramentas trocáveis.
+- 🔍 **Fase de Pesquisa:** Coleta proativa de informações através de ferramentas antes da geração de resposta — reduz alucinações verificando fatos primeiro.
+
+### Por que as equipes escolhem ZeroClaw
+
+- **Enxuto por padrão:** binário Rust pequeno, inicialização rápida, pegada de memória baixa.
+- **Seguro por design:** pareamento, sandboxing estrito, listas de permitidos explícitas, escopo de workspace.
+- **Totalmente trocável:** sistemas principais são traits (provedores, canais, ferramentas, memória, túneis).
+- **Sem lock-in:** suporte de provedor compatível com OpenAI + endpoints personalizados conectáveis.
+
+## Início Rápido
+
+### Opção 1: Homebrew (macOS/Linuxbrew)
+
+```bash
+brew install zeroclaw
+```
+
+### Opção 2: Clonar + Bootstrap
+
+```bash
+git clone https://github.com/zeroclaw-labs/zeroclaw.git
+cd zeroclaw
+./bootstrap.sh
+```
+
+> **Nota:** Builds a partir do fonte requerem ~2GB RAM e ~6GB disco. Para sistemas com recursos limitados, use `./bootstrap.sh --prefer-prebuilt` para baixar um binário pré-compilado.
+
+### Opção 3: Cargo Install
+
+```bash
+cargo install zeroclaw
+```
+
+### Primeira Execução
+
+```bash
+# Iniciar o gateway (serve o API/UI do Dashboard Web)
+zeroclaw gateway
+
+# Abrir a URL do dashboard mostrada nos logs de inicialização
+# (por padrão: http://127.0.0.1:3000/)
+
+# Ou conversar diretamente
+zeroclaw chat "Olá!"
+```
+
+Para opções de configuração detalhadas, consulte [docs/one-click-bootstrap.md](../../../docs/one-click-bootstrap.md).
+
+---
+
+## ⚠️ Repositório Oficial e Aviso de Representação
+
+**Este é o único repositório oficial do ZeroClaw:**
+
+> https://github.com/zeroclaw-labs/zeroclaw
+
+Qualquer outro repositório, organização, domínio ou pacote que afirme ser "ZeroClaw" ou implique afiliação com ZeroClaw Labs **não está autorizado e não é afiliado com este projeto**.
+
+Se você encontrar representação ou uso indevido de marca, por favor [abra uma issue](https://github.com/zeroclaw-labs/zeroclaw/issues).
+
+---
+
+## Licença
+
+ZeroClaw tem licença dupla para máxima abertura e proteção de contribuidores:
+
+| Licença | Caso de uso |
+|---|---|
+| [MIT](../../../LICENSE-MIT) | Open-source, pesquisa, acadêmico, uso pessoal |
+| [Apache 2.0](../../../LICENSE-APACHE) | Proteção de patentes, institucional, implantação comercial |
+
+Você pode escolher qualquer uma das licenças. **Os contribuidores concedem automaticamente direitos sob ambas** — consulte [CLA.md](../../../CLA.md) para o acordo completo de contribuidor.
+
+## Contribuindo
+
+Consulte [CONTRIBUTING.md](../../../CONTRIBUTING.md) e [CLA.md](../../../CLA.md). Implemente uma trait, envie um PR.
+
+---
+
+**ZeroClaw** — Sobrecarga zero. Compromisso zero. Implante em qualquer lugar. Troque qualquer coisa. 🦀
+
+---
+
+## Star History
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/i18n/ru/commands-reference.md b/docs/i18n/ru/commands-reference.md
index 5ba917fcb..419e5ebc7 100644
--- a/docs/i18n/ru/commands-reference.md
+++ b/docs/i18n/ru/commands-reference.md
@@ -20,3 +20,4 @@
## Последнее обновление
- `zeroclaw gateway` поддерживает `--new-pairing`: флаг очищает сохранённые paired-токены и генерирует новый код сопряжения.
+- В английский оригинал добавлены поверхности миграции OpenClaw: `zeroclaw onboard --migrate-openclaw`, `zeroclaw migrate openclaw` и агентный инструмент `openclaw_migration` (полная локализация этих пунктов в процессе).
diff --git a/docs/i18n/ru/config-reference.md b/docs/i18n/ru/config-reference.md
index 795f400d7..9747b1791 100644
--- a/docs/i18n/ru/config-reference.md
+++ b/docs/i18n/ru/config-reference.md
@@ -16,3 +16,12 @@
- Названия config keys не переводятся.
- Точное runtime-поведение определяется английским оригиналом.
+
+## Обновление (2026-03-03)
+
+- В секции `[agent]` добавлены `allowed_tools` и `denied_tools`.
+ - Если `allowed_tools` не пуст, основному агенту показываются только инструменты из allowlist.
+ - `denied_tools` применяется после allowlist и дополнительно исключает инструменты.
+- Неизвестные элементы `allowed_tools` пропускаются (с debug-логом) и не ломают запуск.
+- Если одновременно заданы `allowed_tools` и `denied_tools`, и после фильтрации не остается исполняемых инструментов, запуск завершается fail-fast с явной ошибкой конфигурации.
+- Полная таблица параметров и пример остаются в английском `config-reference.md` в разделе `[agent]`.
diff --git a/docs/i18n/ru/providers-reference.md b/docs/i18n/ru/providers-reference.md
index ec5b48c9c..fec23b11f 100644
--- a/docs/i18n/ru/providers-reference.md
+++ b/docs/i18n/ru/providers-reference.md
@@ -16,3 +16,24 @@
- Provider ID и имена env переменных не переводятся.
- Нормативное описание поведения — в английском оригинале.
+
+## Обновления
+
+- 2026-03-01: добавлена поддержка провайдера StepFun (`stepfun`, алиасы `step`, `step-ai`, `step_ai`).
+
+## StepFun (Кратко)
+
+- Provider ID: `stepfun`
+- Алиасы: `step`, `step-ai`, `step_ai`
+- Base API URL: `https://api.stepfun.com/v1`
+- Эндпоинты: `POST /v1/chat/completions`, `GET /v1/models`
+- Переменная авторизации: `STEP_API_KEY` (fallback: `STEPFUN_API_KEY`)
+- Модель по умолчанию: `step-3.5-flash`
+
+Быстрая проверка:
+
+```bash
+export STEP_API_KEY="your-stepfun-api-key"
+zeroclaw models refresh --provider stepfun
+zeroclaw agent --provider stepfun --model step-3.5-flash -m "ping"
+```
diff --git a/docs/i18n/vi/arduino-uno-q-setup.md b/docs/i18n/vi/arduino-uno-q-setup.md
index 432ed0cf2..c040af5b1 100644
--- a/docs/i18n/vi/arduino-uno-q-setup.md
+++ b/docs/i18n/vi/arduino-uno-q-setup.md
@@ -66,7 +66,7 @@ sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev
# Clone zeroclaw (hoặc scp project của bạn)
-git clone https://github.com/theonlyhennygod/zeroclaw.git
+git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
# Build (~15–30 phút trên Uno Q)
@@ -199,7 +199,7 @@ Giờ khi bạn nhắn tin cho Telegram bot *"Turn on the LED"* hoặc *"Set pin
| 2 | `ssh arduino@` |
| 3 | `curl -sSf https://sh.rustup.rs \| sh -s -- -y && source ~/.cargo/env` |
| 4 | `sudo apt-get install -y pkg-config libssl-dev` |
-| 5 | `git clone https://github.com/theonlyhennygod/zeroclaw.git && cd zeroclaw` |
+| 5 | `git clone https://github.com/zeroclaw-labs/zeroclaw.git && cd zeroclaw` |
| 6 | `cargo build --release --no-default-features` |
| 7 | `zeroclaw onboard --api-key KEY --provider openrouter` |
| 8 | Chỉnh sửa `~/.zeroclaw/config.toml` (thêm Telegram bot_token) |
diff --git a/docs/i18n/vi/ci-map.md b/docs/i18n/vi/ci-map.md
index 0a26afe63..11d9417f0 100644
--- a/docs/i18n/vi/ci-map.md
+++ b/docs/i18n/vi/ci-map.md
@@ -105,7 +105,7 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
8. Cảnh báo drift tính tái lập build: kiểm tra artifact của `.github/workflows/ci-reproducible-build.yml`.
9. Lỗi provenance/ký số: kiểm tra log và bundle artifact của `.github/workflows/ci-supply-chain-provenance.yml`.
10. Sự cố lập kế hoạch/thực thi rollback: kiểm tra summary + artifact `ci-rollback-plan` của `.github/workflows/ci-rollback.yml`.
-11. PR intake thất bại: kiểm tra comment sticky `.github/workflows/pr-intake-checks.yml` và run log.
+11. PR intake thất bại: kiểm tra comment sticky `.github/workflows/pr-intake-checks.yml` và run log. Nếu policy intake vừa thay đổi, hãy kích hoạt sự kiện `pull_request_target` mới (ví dụ close/reopen PR) vì `Re-run jobs` có thể dùng lại snapshot workflow cũ.
12. Lỗi parity chính sách nhãn: kiểm tra `.github/workflows/pr-label-policy-check.yml`.
13. Lỗi tài liệu trong CI: kiểm tra log job `docs-quality` trong `.github/workflows/ci-run.yml`.
14. Lỗi strict delta lint trong CI: kiểm tra log job `lint-strict-delta` và so sánh với phạm vi diff `BASE_SHA`.
@@ -115,7 +115,8 @@ Các kiểm tra chặn merge nên giữ nhỏ và mang tính quyết định. C
- Giữ các kiểm tra chặn merge mang tính quyết định và tái tạo được (`--locked` khi áp dụng được).
- Đảm bảo tương thích merge queue bằng cách hỗ trợ `merge_group` cho các workflow bắt buộc (`ci-run`, `sec-audit`, `sec-codeql`).
-- Bắt buộc PR liên kết với Linear issue key (`RMN-*`/`CDV-*`/`COM-*`) qua PR intake checks.
+- Khuyến nghị PR liên kết với Linear issue key (`RMN-*`/`CDV-*`/`COM-*`) khi có để truy vết (PR intake checks chỉ cảnh báo, không chặn merge).
+- Với backfill PR intake, ưu tiên kích hoạt sự kiện PR mới thay vì rerun run cũ để đảm bảo check đánh giá theo snapshot workflow/script mới nhất.
- Bắt buộc entry `advisories.ignore` trong `deny.toml` dùng object có `id` + `reason` (được kiểm tra bởi `deny_policy_guard.py`).
- Giữ metadata governance cho deny ignore trong `.github/security/deny-ignore-governance.json` luôn cập nhật (owner/reason/expiry/ticket được kiểm tra bởi `deny_policy_guard.py`).
- Giữ metadata quản trị allowlist gitleaks trong `.github/security/gitleaks-allowlist-governance.json` luôn cập nhật (owner/reason/expiry/ticket được kiểm tra bởi `secrets_governance_guard.py`).
diff --git a/docs/i18n/vi/commands-reference.md b/docs/i18n/vi/commands-reference.md
index de9faa09b..d4b37818a 100644
--- a/docs/i18n/vi/commands-reference.md
+++ b/docs/i18n/vi/commands-reference.md
@@ -36,6 +36,8 @@ Xác minh lần cuối: **2026-02-28**.
- `zeroclaw onboard --channels-only`
- `zeroclaw onboard --api-key --provider --memory `
- `zeroclaw onboard --api-key --provider --model --memory `
+- `zeroclaw onboard --migrate-openclaw`
+- `zeroclaw onboard --migrate-openclaw --openclaw-source --openclaw-config `
### `agent`
@@ -77,7 +79,7 @@ Xác minh lần cuối: **2026-02-28**.
- `zeroclaw models refresh --provider `
- `zeroclaw models refresh --force`
-`models refresh` hiện hỗ trợ làm mới danh mục trực tiếp cho các provider: `openrouter`, `openai`, `anthropic`, `groq`, `mistral`, `deepseek`, `xai`, `together-ai`, `gemini`, `ollama`, `llamacpp`, `sglang`, `vllm`, `astrai`, `venice`, `fireworks`, `cohere`, `moonshot`, `glm`, `zai`, `qwen`, `volcengine` (alias `doubao`/`ark`), `siliconflow` và `nvidia`.
+`models refresh` hiện hỗ trợ làm mới danh mục trực tiếp cho các provider: `openrouter`, `openai`, `anthropic`, `groq`, `mistral`, `deepseek`, `xai`, `together-ai`, `gemini`, `ollama`, `llamacpp`, `sglang`, `vllm`, `astrai`, `venice`, `fireworks`, `cohere`, `moonshot`, `stepfun`, `glm`, `zai`, `qwen`, `volcengine` (alias `doubao`/`ark`), `siliconflow` và `nvidia`.
### `channel`
@@ -120,7 +122,9 @@ Skill manifest (`SKILL.toml`) hỗ trợ `prompts` và `[[tools]]`; cả hai đ
### `migrate`
-- `zeroclaw migrate openclaw [--source ] [--dry-run]`
+- `zeroclaw migrate openclaw [--source ] [--source-config ] [--dry-run]`
+
+Gợi ý: trong hội thoại agent, bề mặt tool `openclaw_migration` cho phép preview hoặc áp dụng migration bằng tool-call có kiểm soát quyền.
### `config`
diff --git a/docs/i18n/vi/config-reference.md b/docs/i18n/vi/config-reference.md
index 41b5f3b12..034e9a949 100644
--- a/docs/i18n/vi/config-reference.md
+++ b/docs/i18n/vi/config-reference.md
@@ -81,6 +81,8 @@ Lưu ý cho người dùng container:
| `max_history_messages` | `50` | Số tin nhắn lịch sử tối đa giữ lại mỗi phiên |
| `parallel_tools` | `false` | Bật thực thi tool song song trong một lượt |
| `tool_dispatcher` | `auto` | Chiến lược dispatch tool |
+| `allowed_tools` | `[]` | Allowlist tool cho agent chính. Khi không rỗng, chỉ các tool liệt kê mới được đưa vào context |
+| `denied_tools` | `[]` | Denylist tool cho agent chính, áp dụng sau `allowed_tools` |
Lưu ý:
@@ -88,6 +90,25 @@ Lưu ý:
- Nếu tin nhắn kênh vượt giá trị này, runtime trả về: `Agent exceeded maximum tool iterations ()`.
- Trong vòng lặp tool của CLI, gateway và channel, các lời gọi tool độc lập được thực thi đồng thời mặc định khi không cần phê duyệt; thứ tự kết quả giữ ổn định.
- `parallel_tools` áp dụng cho API `Agent::turn()`. Không ảnh hưởng đến vòng lặp runtime của CLI, gateway hay channel.
+- `allowed_tools` / `denied_tools` được áp dụng lúc khởi động trước khi dựng prompt. Tool bị loại sẽ không xuất hiện trong system prompt hoặc tool specs.
+- Mục không khớp trong `allowed_tools` được bỏ qua (không làm lỗi khởi động) và ghi log mức debug.
+- Nếu đồng thời đặt `allowed_tools` và `denied_tools` rồi denylist loại toàn bộ tool đã allow, tiến trình sẽ fail-fast với lỗi cấu hình rõ ràng.
+
+Ví dụ:
+
+```toml
+[agent]
+allowed_tools = [
+ "delegate",
+ "subagent_spawn",
+ "subagent_list",
+ "subagent_manage",
+ "memory_recall",
+ "memory_store",
+ "task_plan",
+]
+denied_tools = ["shell", "file_write", "browser_open"]
+```
## `[agents.]`
@@ -530,6 +551,7 @@ Lưu ý:
- Allowlist kênh mặc định từ chối tất cả (`[]` nghĩa là từ chối tất cả)
- Gateway mặc định yêu cầu ghép nối
- Mặc định chặn public bind
+- `security.canary_tokens = true` bật canary token theo từng lượt để phát hiện rò rỉ ngữ cảnh hệ thống
## Lệnh kiểm tra
diff --git a/docs/i18n/vi/providers-reference.md b/docs/i18n/vi/providers-reference.md
index 32b347644..f000768a6 100644
--- a/docs/i18n/vi/providers-reference.md
+++ b/docs/i18n/vi/providers-reference.md
@@ -2,7 +2,7 @@
Tài liệu này liệt kê các provider ID, alias và biến môi trường chứa thông tin xác thực.
-Cập nhật lần cuối: **2026-02-28**.
+Cập nhật lần cuối: **2026-03-01**.
## Cách liệt kê các Provider
@@ -33,6 +33,7 @@ Với chuỗi provider dự phòng (`reliability.fallback_providers`), mỗi pro
| `vercel` | `vercel-ai` | Không | `VERCEL_API_KEY` |
| `cloudflare` | `cloudflare-ai` | Không | `CLOUDFLARE_API_KEY` |
| `moonshot` | `kimi` | Không | `MOONSHOT_API_KEY` |
+| `stepfun` | `step`, `step-ai`, `step_ai` | Không | `STEP_API_KEY`, `STEPFUN_API_KEY` |
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | Không | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
| `synthetic` | — | Không | `SYNTHETIC_API_KEY` |
| `opencode` | `opencode-zen` | Không | `OPENCODE_API_KEY` |
@@ -87,6 +88,29 @@ zeroclaw models refresh --provider volcengine
zeroclaw agent --provider volcengine --model doubao-1-5-pro-32k-250115 -m "ping"
```
+### Ghi chú về StepFun
+
+- Provider ID: `stepfun` (alias: `step`, `step-ai`, `step_ai`)
+- Base API URL: `https://api.stepfun.com/v1`
+- Chat endpoint: `/chat/completions`
+- Model discovery endpoint: `/models`
+- Xác thực: `STEP_API_KEY` (fallback: `STEPFUN_API_KEY`)
+- Model mặc định: `step-3.5-flash`
+
+Ví dụ thiết lập nhanh:
+
+```bash
+export STEP_API_KEY="your-stepfun-api-key"
+zeroclaw onboard --provider stepfun --api-key "$STEP_API_KEY" --model step-3.5-flash --force
+```
+
+Kiểm tra nhanh:
+
+```bash
+zeroclaw models refresh --provider stepfun
+zeroclaw agent --provider stepfun --model step-3.5-flash -m "ping"
+```
+
### Ghi chú về SiliconFlow
- Provider ID: `siliconflow` (alias: `silicon-cloud`, `siliconcloud`)
diff --git a/docs/i18n/zh-CN/commands-reference.md b/docs/i18n/zh-CN/commands-reference.md
index 4a0159c80..bdb20e9a8 100644
--- a/docs/i18n/zh-CN/commands-reference.md
+++ b/docs/i18n/zh-CN/commands-reference.md
@@ -20,3 +20,4 @@
## 最近更新
- `zeroclaw gateway` 新增 `--new-pairing` 参数,可清空已配对 token 并在网关启动时生成新的配对码。
+- OpenClaw 迁移相关命令已加入英文原文:`zeroclaw onboard --migrate-openclaw`、`zeroclaw migrate openclaw`,并新增 agent 工具 `openclaw_migration`(本地化条目待补全,先以英文原文为准)。
diff --git a/docs/i18n/zh-CN/config-reference.md b/docs/i18n/zh-CN/config-reference.md
index 8e42e87b0..74306034b 100644
--- a/docs/i18n/zh-CN/config-reference.md
+++ b/docs/i18n/zh-CN/config-reference.md
@@ -16,3 +16,12 @@
- 配置键保持英文,避免本地化改写键名。
- 生产行为以英文原文定义为准。
+
+## 更新说明(2026-03-03)
+
+- `[agent]` 新增 `allowed_tools` 与 `denied_tools`:
+ - `allowed_tools` 非空时,只向主代理暴露白名单工具。
+ - `denied_tools` 在白名单过滤后继续移除工具。
+- 未匹配的 `allowed_tools` 项会被跳过(调试日志提示),不会导致启动失败。
+- 若同时配置 `allowed_tools` 与 `denied_tools` 且最终将可执行工具全部移除,启动会快速失败并给出明确错误。
+- 详细字段表与示例见英文原文 `config-reference.md` 的 `[agent]` 小节。
diff --git a/docs/i18n/zh-CN/providers-reference.md b/docs/i18n/zh-CN/providers-reference.md
index bb6268b00..326be0866 100644
--- a/docs/i18n/zh-CN/providers-reference.md
+++ b/docs/i18n/zh-CN/providers-reference.md
@@ -16,3 +16,25 @@
- Provider ID 与环境变量名称保持英文。
- 规范与行为说明以英文原文为准。
+
+## 更新记录
+
+- 2026-03-01:新增 StepFun provider 对齐信息(`stepfun` / `step` / `step-ai` / `step_ai`)。
+
+## StepFun 快速说明
+
+- Provider ID:`stepfun`
+- 别名:`step`、`step-ai`、`step_ai`
+- Base API URL:`https://api.stepfun.com/v1`
+- 模型列表端点:`GET /v1/models`
+- 对话端点:`POST /v1/chat/completions`
+- 鉴权变量:`STEP_API_KEY`(回退:`STEPFUN_API_KEY`)
+- 默认模型:`step-3.5-flash`
+
+快速验证:
+
+```bash
+export STEP_API_KEY="your-stepfun-api-key"
+zeroclaw models refresh --provider stepfun
+zeroclaw agent --provider stepfun --model step-3.5-flash -m "ping"
+```
diff --git a/docs/migration/openclaw-migration-guide.md b/docs/migration/openclaw-migration-guide.md
index a53fcaad3..26e67db02 100644
--- a/docs/migration/openclaw-migration-guide.md
+++ b/docs/migration/openclaw-migration-guide.md
@@ -2,7 +2,31 @@
This guide walks you through migrating an OpenClaw deployment to ZeroClaw. It covers configuration conversion, endpoint changes, and the architectural differences you need to know.
-## Quick Start
+## Quick Start (Built-in Merge Migration)
+
+ZeroClaw now includes a built-in OpenClaw migration flow:
+
+```bash
+# Preview migration report (no writes)
+zeroclaw migrate openclaw --dry-run
+
+# Apply merge migration (memory + config + agents)
+zeroclaw migrate openclaw
+
+# Optional: run migration during onboarding
+zeroclaw onboard --migrate-openclaw
+```
+
+Localization status: this guide currently ships in English only. Localized follow-through for `zh-CN`, `ja`, `ru`, `fr`, `vi`, and `el` is deferred; translators should carry over the exact CLI forms `zeroclaw migrate openclaw` and `zeroclaw onboard --migrate-openclaw` first.
+
+Default migration semantics are **merge-first**:
+
+- Existing ZeroClaw values are preserved (no blind overwrite).
+- Missing provider/model/channel/agent fields are filled from OpenClaw.
+- List-like fields (for example agent tools / allowlists) are union-merged with de-duplication.
+- Memory import skips duplicate content to reduce noise while keeping existing data.
+
+## Legacy Conversion Script (Optional)
```bash
# 1. Convert your OpenClaw config
diff --git a/docs/nextcloud-talk-setup.md b/docs/nextcloud-talk-setup.md
index a2c445a6a..a870b3dc9 100644
--- a/docs/nextcloud-talk-setup.md
+++ b/docs/nextcloud-talk-setup.md
@@ -60,9 +60,29 @@ If verification fails, the gateway returns `401 Unauthorized`.
## 5. Message routing behavior
-- ZeroClaw ignores bot-originated webhook events (`actorType = bots`).
+- ZeroClaw accepts both payload variants:
+ - legacy Talk webhook payloads (`type = "message"`)
+ - Activity Streams 2.0 payloads (`type = "Create"` + `object.type = "Note"`)
+- ZeroClaw ignores bot-originated webhook events (`actorType = bots` or `actor.type = "Application"`).
- ZeroClaw ignores non-message/system events.
-- Reply routing uses the Talk room token from the webhook payload.
+- Reply routing uses the Talk room token from `object.token` (legacy) or `target.id` (AS2).
+- For actor allowlists, both full (`users/alice`) and short (`alice`) IDs are accepted.
+
+Example Activity Streams 2.0 webhook payload:
+
+```json
+{
+ "type": "Create",
+ "actor": { "type": "Person", "id": "users/test", "name": "test" },
+ "object": {
+ "type": "Note",
+ "id": "177",
+ "content": "{\"message\":\"hello\",\"parameters\":[]}",
+ "mediaType": "text/markdown"
+ },
+ "target": { "type": "Collection", "id": "yyrubgfp", "name": "TESTCHAT" }
+}
+```
## 6. Quick validation checklist
diff --git a/docs/one-click-bootstrap.md b/docs/one-click-bootstrap.md
index f2d8ddb37..9cd4ae5ae 100644
--- a/docs/one-click-bootstrap.md
+++ b/docs/one-click-bootstrap.md
@@ -2,7 +2,7 @@
This page defines the fastest supported path to install and initialize ZeroClaw.
-Last verified: **February 20, 2026**.
+Last verified: **March 4, 2026**.
## Option 0: Homebrew (macOS/Linuxbrew)
@@ -22,6 +22,7 @@ What it does by default:
1. `cargo build --release --locked`
2. `cargo install --path . --force --locked`
+3. In interactive no-flag sessions, launches TUI onboarding (`zeroclaw onboard --interactive-ui`)
### Resource preflight and pre-built flow
@@ -50,7 +51,8 @@ To bypass pre-built flow and force source compilation:
## Dual-mode bootstrap
-Default behavior is **app-only** (build/install ZeroClaw) and expects existing Rust toolchain.
+Default behavior builds/install ZeroClaw and, for interactive no-flag runs, starts TUI onboarding.
+It still expects an existing Rust toolchain unless you enable bootstrap flags below.
For fresh machines, enable environment bootstrap explicitly:
@@ -69,11 +71,19 @@ Notes:
## Option B: Remote one-liner
```bash
-curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/bootstrap.sh | bash
+curl -fsSL https://zeroclawlabs.ai/install.sh | bash
+```
+
+Equivalent GitHub-hosted installer entrypoint:
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
```
For high-security environments, prefer Option A so you can review the script before execution.
+No-arg interactive runs default to full-screen TUI onboarding.
+
Legacy compatibility:
```bash
@@ -124,6 +134,8 @@ ZEROCLAW_API_KEY="sk-..." ZEROCLAW_PROVIDER="openrouter" ./bootstrap.sh --onboar
./bootstrap.sh --interactive-onboard
```
+This launches the full-screen TUI onboarding flow (`zeroclaw onboard --interactive-ui`).
+
## Useful flags
- `--install-system-deps`
diff --git a/docs/operations/incident-2026-03-02-main-red-runner-regression.md b/docs/operations/incident-2026-03-02-main-red-runner-regression.md
new file mode 100644
index 000000000..a6167f30e
--- /dev/null
+++ b/docs/operations/incident-2026-03-02-main-red-runner-regression.md
@@ -0,0 +1,108 @@
+# CI Runner Incident Report: main branch red on 2026-03-02
+
+This report is for CI runner maintainers to debug runner health regressions first, before restoring self-hosted execution for critical workflows.
+
+## Scope
+
+- Repo: `zeroclaw-labs/zeroclaw`
+- Date window: 2026-03-02 (UTC)
+- Impacted checks:
+ - `CI Supply Chain Provenance / Build + Provenance Bundle (push)`
+ - `Test E2E / Integration / E2E Tests (push)`
+
+## Executive Summary
+
+`main` became red due to runner-environment failures in self-hosted pools.
+
+Observed failure classes:
+
+1. Missing C compiler linker (`cc`) causing Rust build-script compile failures.
+2. Disk exhaustion (`No space left on device`) on at least one self-hosted E2E run.
+
+These are host-level failures and were reproduced across unrelated merge commits.
+
+## Evidence
+
+| Time (UTC) | Workflow run | Commit | Runner | Failure signature |
+|---|---|---|---|---|
+| 2026-03-02T02:04:42Z | https://github.com/zeroclaw-labs/zeroclaw/actions/runs/22558446611 | `4b16ac92197d98bd64a43ae750d473b9f1c6d66d` | `runner-a` (`self-hosted-pool`) | `error: linker 'cc' not found` + `No such file or directory (os error 2)` |
+| 2026-03-02T02:04:42Z | https://github.com/zeroclaw-labs/zeroclaw/actions/runs/22558446636 | `4b16ac92197d98bd64a43ae750d473b9f1c6d66d` | `runner-b` (`self-hosted-pool`) | `error: linker 'cc' not found` + `No such file or directory (os error 2)` |
+| 2026-03-02T01:54:26Z | https://github.com/zeroclaw-labs/zeroclaw/actions/runs/22558247107 | `b8e5707d180004fe00fa12bfacd1bcf29f195457` | `runner-c` (`self-hosted-pool`) | `error: linker 'cc' not found` + `No such file or directory (os error 2)` |
+| 2026-03-02T01:25:15Z | https://github.com/zeroclaw-labs/zeroclaw/actions/runs/22557668884 | `64a2a271c74fc84276e98231196b749f29276d17` | `runner-d` (`self-hosted-pool`) | `error: linker 'cc' not found` + `No such file or directory (os error 2)` |
+| 2026-03-02T01:25:15Z | https://github.com/zeroclaw-labs/zeroclaw/actions/runs/22557668895 | `64a2a271c74fc84276e98231196b749f29276d17` | `runner-e` (`self-hosted-pool`) | `No space left on device` |
+
+## Why this is runner infra
+
+- Same `cc` failure appears in multiple independent merges.
+- Failure happens within ~11-15 seconds during bootstrap/compile stage.
+- Similar test lane succeeded in nearby window on a different runner host, indicating host drift rather than deterministic code break.
+
+## Debug Procedure (Runner Maintainers)
+
+Run on each affected host and attach outputs to incident ticket.
+
+```bash
+# identity
+hostname
+uname -a
+
+# required build toolchain
+command -v cc || true
+command -v gcc || true
+command -v clang || true
+command -v rustc || true
+command -v cargo || true
+ls -l /usr/bin/cc || true
+
+# versions
+cc --version || true
+gcc --version | head -n1 || true
+clang --version | head -n1 || true
+rustc --version || true
+cargo --version || true
+
+# disk and inode pressure
+df -h /
+df -h /opt/actions-runners || true
+df -Pi /
+df -Pi /opt/actions-runners || true
+
+# top disk consumers
+du -h /opt/actions-runners --max-depth=2 2>/dev/null | sort -h | tail -n 40
+
+# runner service logs (service name may vary)
+sudo journalctl -u actions.runner\* --since "2026-03-02 00:00:00" -n 300 --no-pager || true
+```
+
+If `cc` is missing:
+
+```bash
+sudo apt-get update
+sudo apt-get install -y build-essential pkg-config clang
+command -v cc || sudo ln -sf /usr/bin/gcc /usr/bin/cc
+cc --version
+```
+
+If disk is low / inode pressure is high:
+
+```bash
+sudo du -h /opt/actions-runners --max-depth=3 | sort -h | tail -n 60
+# clean stale _work/_temp/_diag artifacts per runner ops policy
+```
+
+## Mitigation Applied in This PR
+
+1. Immediate unblock on `main`:
+ - `test-e2e.yml` moved to `ubuntu-22.04`.
+ - `ci-supply-chain-provenance.yml` moved to `ubuntu-22.04`.
+2. Preflight hardening:
+ - added explicit checks for `cc` and free disk (>=10 GiB) in those jobs.
+3. Root-cause visibility:
+ - `test-self-hosted.yml` now includes compiler + disk/inode checks and daily schedule.
+
+## Exit Criteria to move lanes back to self-hosted
+
+1. Self-hosted health workflow passes on representative nodes.
+2. 10 consecutive critical runs pass on self-hosted without `cc` or ENOSPC failures.
+3. Runner image baseline explicitly includes compiler/runtime prerequisites and cleanup policy.
+4. Health checks remain stable for 24h after rollback from hosted fallback.
diff --git a/docs/operations/required-check-mapping.md b/docs/operations/required-check-mapping.md
index fe4aba9a7..ccf6b6245 100644
--- a/docs/operations/required-check-mapping.md
+++ b/docs/operations/required-check-mapping.md
@@ -7,9 +7,14 @@ This document maps merge-critical workflows to expected check names.
| Required check name | Source workflow | Scope |
| --- | --- | --- |
| `CI Required Gate` | `.github/workflows/ci-run.yml` | core Rust/doc merge gate |
-| `Security Audit` | `.github/workflows/sec-audit.yml` | dependencies, secrets, governance |
-| `Feature Matrix Summary` | `.github/workflows/feature-matrix.yml` | feature-combination compile matrix |
-| `Workflow Sanity` | `.github/workflows/workflow-sanity.yml` | workflow syntax and lint |
+| `Security Required Gate` | `.github/workflows/sec-audit.yml` | aggregated security merge gate |
+
+Supplemental monitors (non-blocking unless added to branch protection contexts):
+
+- `CI Change Audit` (`.github/workflows/ci-change-audit.yml`)
+- `CodeQL Analysis` (`.github/workflows/sec-codeql.yml`)
+- `Workflow Sanity` (`.github/workflows/workflow-sanity.yml`)
+- `Feature Matrix Summary` (`.github/workflows/feature-matrix.yml`)
Feature matrix lane check names (informational, non-required):
@@ -28,12 +33,14 @@ Feature matrix lane check names (informational, non-required):
## Verification Procedure
-1. Resolve latest workflow run IDs:
+1. Check active branch protection required contexts:
+ - `gh api repos/zeroclaw-labs/zeroclaw/branches/main/protection --jq '.required_status_checks.contexts[]'`
+2. Resolve latest workflow run IDs:
- `gh run list --repo zeroclaw-labs/zeroclaw --workflow feature-matrix.yml --limit 1`
- `gh run list --repo zeroclaw-labs/zeroclaw --workflow ci-run.yml --limit 1`
-2. Enumerate check/job names and compare to this mapping:
+3. Enumerate check/job names and compare to this mapping:
- `gh run view --repo zeroclaw-labs/zeroclaw --json jobs --jq '.jobs[].name'`
-3. If any merge-critical check name changed, update this file before changing branch protection policy.
+4. If any merge-critical check name changed, update this file before changing branch protection policy.
## Notes
diff --git a/docs/operations/self-hosted-runner-remediation.md b/docs/operations/self-hosted-runner-remediation.md
index 3f6455d51..25c959195 100644
--- a/docs/operations/self-hosted-runner-remediation.md
+++ b/docs/operations/self-hosted-runner-remediation.md
@@ -83,6 +83,20 @@ Safety behavior:
4. Drain runners, then apply cleanup.
5. Re-run health report and confirm queue/availability recovery.
+## 3.1) Build Smoke Exit `143` Triage
+
+When `CI Run / Build (Smoke)` fails with `Process completed with exit code 143`:
+
+1. Treat it as external termination (SIGTERM), not a compile error.
+2. Confirm the build step ended with `Terminated` and no Rust compiler diagnostic was emitted.
+3. Check current pool pressure (`runner_health_report.py`) before retrying.
+4. Re-run once after pressure drops; persistent `143` should be handled as runner-capacity remediation.
+
+Important:
+
+- `error: cannot install while Rust is installed` from rustup bootstrap can appear in setup logs on pre-provisioned runners.
+- That message is not itself a terminal failure when subsequent `rustup toolchain install` and `rustup default` succeed.
+
## 4) Queue Hygiene (Dry-Run First)
Dry-run example:
diff --git a/docs/plans/2026-02-22-wasm-plugin-runtime-design.md b/docs/plans/2026-02-22-wasm-plugin-runtime-design.md
new file mode 100644
index 000000000..1bb422f6e
--- /dev/null
+++ b/docs/plans/2026-02-22-wasm-plugin-runtime-design.md
@@ -0,0 +1,178 @@
+# WASM Plugin Runtime Design (Capability-Segmented, WASI Preview 2)
+
+## Context
+
+ZeroClaw currently uses in-process trait/factory extension points for providers, tools, channels, memory, runtime adapters, observers, peripherals, and hooks. Hook interfaces exist, but several lifecycle events are either missing or not wired in runtime paths.
+
+## Objective
+
+Design and implement a production-safe system WASM plugin runtime that supports:
+- hook plugins
+- tool plugins
+- provider plugins
+- `BeforeCompaction` / `AfterCompaction` hook points
+- `ToolResultPersist` modifying hook
+- `ObserverBridge` (legacy observer -> hook adapter)
+- `fire_gateway_stop` runtime wiring
+- built-in `session_memory` and `boot_script` hooks
+- hot-reload without service restart
+
+## Chosen Direction
+
+Capability-segmented plugin API on WASI Preview 2 + WIT.
+
+Why:
+- cleaner authoring surface than a monolithic plugin ABI
+- stronger permission boundaries per capability
+- easier long-term compatibility/versioning
+- lower blast radius for failures and upgrades
+
+## Architecture
+
+### 1. Plugin Subsystem
+
+Add `src/plugins/` as first-class subsystem:
+- `src/plugins/mod.rs`
+- `src/plugins/traits.rs`
+- `src/plugins/manifest.rs`
+- `src/plugins/runtime.rs`
+- `src/plugins/registry.rs`
+- `src/plugins/hot_reload.rs`
+- `src/plugins/bridge/observer.rs`
+
+### 2. WIT Contracts
+
+Define separate contracts under `wit/zeroclaw/`:
+- `hooks/v1`
+- `tools/v1`
+- `providers/v1`
+
+Each contract has independent semver policy and compatibility checks.
+
+### 3. Capability Model
+
+Manifest-declared capabilities are deny-by-default.
+Host grants capability-specific rights through config policy.
+Examples:
+- `hooks`
+- `tools.execute`
+- `providers.chat`
+- optional I/O scopes (network/fs/secrets) via explicit allowlists
+
+### 4. Runtime Lifecycle
+
+1. Discover plugin manifests in configured directories.
+2. Validate metadata (ABI version, checksum/signature policy, capabilities).
+3. Instantiate plugin runtime components in immutable snapshot.
+4. Register plugin-provided hook handlers, tools, and providers.
+5. Atomically publish snapshot.
+
+### 5. Dispatch Model
+
+#### Hooks
+
+- Void hooks: bounded parallel fanout + timeout.
+- Modifying hooks: deterministic ordered pipeline (priority desc, stable plugin-id tie-breaker).
+
+#### Tools
+
+- Merge native and plugin tool specs.
+- Route tool calls by ownership.
+- Keep host-side security policy enforcement before plugin execution.
+- Apply `ToolResultPersist` modifying hook before final persistence and feedback.
+
+#### Providers
+
+- Extend provider factory lookup to include plugin provider registry.
+- Plugin providers participate in existing resilience and routing wrappers.
+
+### 6. New Hook Points
+
+Add and wire:
+- `BeforeCompaction`
+- `AfterCompaction`
+- `ToolResultPersist`
+- `fire_gateway_stop` call site on graceful gateway shutdown
+
+### 7. Built-in Hooks
+
+Provide built-ins loaded through same hook registry:
+- `session_memory`
+- `boot_script`
+
+This keeps runtime behavior consistent between native and plugin hooks.
+
+### 8. ObserverBridge
+
+Add adapter that maps observer events into hook events, preserving legacy observer flows while enabling hook-based plugin processing.
+
+### 9. Hot Reload
+
+- Watch plugin files/manifests.
+- Rebuild and validate candidate snapshot fully.
+- Atomic swap on success.
+- Keep old snapshot if reload fails.
+- In-flight invocations continue on the snapshot they started with.
+
+## Safety and Reliability
+
+- Per-plugin memory/CPU/time/concurrency limits.
+- Invocation timeout and trap isolation.
+- Circuit breaker for repeatedly failing plugins.
+- No plugin error may crash core runtime path.
+- Sensitive payload redaction at host observability boundary.
+
+## Compatibility Strategy
+
+- Independent major-version compatibility checks per WIT package.
+- Reject incompatible plugins at load time with clear diagnostics.
+- Preserve native implementations as fallback path.
+
+## Testing Strategy
+
+### Unit
+
+- manifest parsing and capability policy
+- ABI compatibility checks
+- hook ordering and cancellation semantics
+- timeout/trap handling
+
+### Integration
+
+- plugin tool registration/execution
+- plugin provider routing + fallback
+- compaction hook sequence
+- gateway stop hook firing
+- hot-reload swap/rollback behavior
+
+### Regression
+
+- native-only mode unchanged when plugins disabled
+- security policy enforcement remains intact
+
+## Rollout Plan
+
+1. Foundation: subsystem + config + ABI skeleton.
+2. Hook integration + new hook points + built-ins.
+3. Tool plugin routing.
+4. Provider plugin routing.
+5. Hot reload + ObserverBridge.
+6. SDK + docs + example plugins.
+
+## Non-goals (v1)
+
+- dynamic cross-plugin dependency resolution
+- distributed remote plugin registries
+- automatic plugin marketplace installation
+
+## Risks
+
+- ABI churn if contracts are not tightly scoped.
+- runtime overhead with poorly bounded plugin execution.
+- operational complexity from hot-reload races.
+
+## Mitigations
+
+- capability segmentation + strict semver.
+- hard limits and circuit breakers.
+- immutable snapshot architecture for reload safety.
diff --git a/docs/plans/2026-02-22-wasm-plugin-runtime.md b/docs/plans/2026-02-22-wasm-plugin-runtime.md
new file mode 100644
index 000000000..b17ba6f80
--- /dev/null
+++ b/docs/plans/2026-02-22-wasm-plugin-runtime.md
@@ -0,0 +1,415 @@
+# WASM Plugin Runtime Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan
+> task-by-task.
+
+**Goal:** Build a WASI Preview 2 + WIT plugin runtime that supports hook/tool/provider plugins, new
+hook points, ObserverBridge, and hot-reload with safe fallback.
+
+**Architecture:** Add a capability-segmented plugin subsystem (`src/plugins/**`) and route
+hook/tool/provider dispatch through immutable plugin snapshots. Keep native implementations intact
+as fallback. Enforce deny-by-default capability policy with host-side limits and deterministic
+modifying-hook ordering.
+
+**Tech Stack:** Rust, Tokio, Wasmtime (component model), WASI Preview 2, WIT, serde, notify,
+existing ZeroClaw traits/factories.
+
+---
+
+## Task 1: Add plugin config schema and defaults
+
+**Files:**
+
+- Modify: `src/config/schema.rs`
+- Modify: `src/config/mod.rs`
+- Test: `src/config/schema.rs` (inline tests)
+
+- Step 1: Write the failing test
+
+```rust
+#[test]
+fn plugins_config_defaults_safe() {
+ let cfg = HooksConfig::default();
+ // replace with PluginConfig once added
+ assert!(cfg.enabled);
+}
+```
+
+- Step 2: Run test to verify it fails Run: `cargo test --locked config::schema -- --nocapture`
+Expected: FAIL because `PluginsConfig` fields/assertions do not exist yet.
+
+- Step 3: Write minimal implementation
+
+- Add `PluginsConfig` with:
+ - `enabled: bool`
+ - `dirs: Vec`
+ - `hot_reload: bool`
+ - `limits` (timeout/memory/concurrency)
+ - capability allow/deny lists
+- Add defaults: disabled-by-default runtime loading, deny-by-default capabilities.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked config::schema -- --nocapture`
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/config/schema.rs src/config/mod.rs
+git commit -m "feat(config): add plugin runtime config schema"
+```
+
+## Task 2: Scaffold plugin subsystem modules
+
+**Files:**
+
+- Create: `src/plugins/mod.rs`
+- Create: `src/plugins/traits.rs`
+- Create: `src/plugins/manifest.rs`
+- Create: `src/plugins/runtime.rs`
+- Create: `src/plugins/registry.rs`
+- Create: `src/plugins/hot_reload.rs`
+- Create: `src/plugins/bridge/mod.rs`
+- Create: `src/plugins/bridge/observer.rs`
+- Modify: `src/lib.rs`
+- Test: inline tests in new modules
+
+- Step 1: Write the failing test
+
+```rust
+#[test]
+fn plugin_registry_empty_by_default() {
+ let reg = PluginRegistry::default();
+ assert!(reg.hooks().is_empty());
+}
+```
+
+- Step 2: Run test to verify it fails Run: `cargo test --locked plugins:: -- --nocapture`
+Expected: FAIL because modules/types do not exist.
+
+- Step 3: Write minimal implementation
+
+- Add module exports and basic structs/enums.
+- Keep runtime no-op while preserving compile-time interfaces.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked plugins:: -- --nocapture`
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/plugins src/lib.rs
+git commit -m "feat(plugins): scaffold plugin subsystem modules"
+```
+
+## Task 3: Add WIT capability contracts and ABI version checks
+
+**Files:**
+
+- Create: `wit/zeroclaw/hooks/v1/*.wit`
+- Create: `wit/zeroclaw/tools/v1/*.wit`
+- Create: `wit/zeroclaw/providers/v1/*.wit`
+- Modify: `src/plugins/manifest.rs`
+- Test: `src/plugins/manifest.rs` inline tests
+
+- Step 1: Write the failing test
+
+```rust
+#[test]
+fn manifest_rejects_incompatible_wit_major() {
+ let m = PluginManifest { wit_package: "zeroclaw:hooks@2.0.0".into(), ..Default::default() };
+ assert!(validate_manifest(&m).is_err());
+}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked manifest_rejects_incompatible_wit_major -- --nocapture` Expected: FAIL before
+validator exists.
+
+- Step 3: Write minimal implementation
+
+- Add WIT package declarations and version policy parser.
+- Validate major compatibility per capability package.
+
+- Step 4: Run test to verify it passes Run:
+`cargo test --locked manifest_rejects_incompatible_wit_major -- --nocapture` Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add wit src/plugins/manifest.rs
+git commit -m "feat(plugins): add wit contracts and abi compatibility checks"
+```
+
+## Task 4: Hook runtime integration and missing lifecycle wiring
+
+**Files:**
+
+- Modify: `src/hooks/traits.rs`
+- Modify: `src/hooks/runner.rs`
+- Modify: `src/gateway/mod.rs`
+- Modify: `src/agent/loop_.rs`
+- Modify: `src/channels/mod.rs`
+- Test: inline tests in `src/hooks/runner.rs`, `src/agent/loop_.rs`
+
+- Step 1: Write the failing test
+
+```rust
+#[tokio::test]
+async fn fire_gateway_stop_is_called_on_shutdown_path() {
+ // assert hook observed stop signal
+}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked fire_gateway_stop_is_called_on_shutdown_path -- --nocapture` Expected: FAIL due
+to missing call site.
+
+- Step 3: Write minimal implementation
+
+- Add hook events: `BeforeCompaction`, `AfterCompaction`, `ToolResultPersist`.
+- Wire `fire_gateway_stop` in graceful shutdown path.
+- Trigger compaction hooks around compaction flows.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked hooks::runner -- --nocapture`
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/hooks src/gateway/mod.rs src/agent/loop_.rs src/channels/mod.rs
+git commit -m "feat(hooks): add compaction/persist hooks and gateway stop lifecycle wiring"
+```
+
+## Task 5: Implement built-in `session_memory` and `boot_script` hooks
+
+**Files:**
+
+- Create: `src/hooks/builtin/session_memory.rs`
+- Create: `src/hooks/builtin/boot_script.rs`
+- Modify: `src/hooks/builtin/mod.rs`
+- Modify: `src/config/schema.rs`
+- Modify: `src/agent/loop_.rs`
+- Modify: `src/channels/mod.rs`
+- Test: inline tests in new builtins
+
+- Step 1: Write the failing test
+
+```rust
+#[tokio::test]
+async fn session_memory_hook_persists_and_recalls_expected_context() {}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked session_memory_hook -- --nocapture` Expected: FAIL before hook exists.
+
+- Step 3: Write minimal implementation
+
+- Register both built-ins through `HookRunner` initialization paths.
+- `session_memory`: persist/retrieve session-scoped summaries.
+- `boot_script`: mutate prompt/context at startup/session begin.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked hooks::builtin -- --nocapture`
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/hooks/builtin src/config/schema.rs src/agent/loop_.rs src/channels/mod.rs
+git commit -m "feat(hooks): add session_memory and boot_script built-in hooks"
+```
+
+## Task 6: Add plugin tool registration and execution routing
+
+**Files:**
+
+- Modify: `src/tools/mod.rs`
+- Modify: `src/tools/traits.rs`
+- Modify: `src/agent/loop_.rs`
+- Modify: `src/plugins/registry.rs`
+- Modify: `src/plugins/runtime.rs`
+- Test: `src/agent/loop_.rs` inline tests, `src/tools/mod.rs` tests
+
+- Step 1: Write the failing test
+
+```rust
+#[tokio::test]
+async fn plugin_tool_spec_is_visible_and_executable() {}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked plugin_tool_spec_is_visible_and_executable -- --nocapture` Expected: FAIL
+before routing exists.
+
+- Step 3: Write minimal implementation
+
+- Merge plugin tool specs with native specs.
+- Route execution by owner.
+- Keep host security checks before plugin invocation.
+- Apply `ToolResultPersist` before persistence/feedback.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked agent::loop_ -- --nocapture`
+Expected: PASS for plugin tool tests.
+
+- Step 5: Commit
+
+```bash
+git add src/tools/mod.rs src/tools/traits.rs src/agent/loop_.rs src/plugins/registry.rs src/plugins/runtime.rs
+git commit -m "feat(tools): support wasm plugin tool registration and execution"
+```
+
+## Task 7: Add plugin provider registration and factory integration
+
+**Files:**
+
+- Modify: `src/providers/mod.rs`
+- Modify: `src/providers/traits.rs`
+- Modify: `src/plugins/registry.rs`
+- Modify: `src/plugins/runtime.rs`
+- Test: `src/providers/mod.rs` inline tests
+
+- Step 1: Write the failing test
+
+```rust
+#[test]
+fn factory_can_create_plugin_provider() {}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked factory_can_create_plugin_provider -- --nocapture` Expected: FAIL before plugin
+provider lookup exists.
+
+- Step 3: Write minimal implementation
+
+- Extend provider factory to resolve plugin providers after native map.
+- Ensure resilient/routed providers can wrap plugin providers.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked providers::mod -- --nocapture`
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/providers/mod.rs src/providers/traits.rs src/plugins/registry.rs src/plugins/runtime.rs
+git commit -m "feat(providers): integrate wasm plugin providers into factory and routing"
+```
+
+## Task 8: Implement ObserverBridge
+
+**Files:**
+
+- Modify: `src/plugins/bridge/observer.rs`
+- Modify: `src/observability/mod.rs`
+- Modify: `src/agent/loop_.rs`
+- Modify: `src/gateway/mod.rs`
+- Test: `src/plugins/bridge/observer.rs` inline tests
+
+- Step 1: Write the failing test
+
+```rust
+#[test]
+fn observer_bridge_emits_hook_events_for_legacy_observer_stream() {}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked observer_bridge_emits_hook_events_for_legacy_observer_stream -- --nocapture`
+Expected: FAIL before bridge wiring.
+
+- Step 3: Write minimal implementation
+
+- Implement adapter mapping observer events into hook dispatch.
+- Wire where observer is created in agent/channel/gateway flows.
+
+- Step 4: Run test to verify it passes Run: `cargo test --locked plugins::bridge -- --nocapture`
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/plugins/bridge/observer.rs src/observability/mod.rs src/agent/loop_.rs src/gateway/mod.rs
+git commit -m "feat(observability): add observer-to-hook bridge for plugin runtime"
+```
+
+## Task 9: Implement hot reload with immutable snapshots
+
+**Files:**
+
+- Modify: `src/plugins/hot_reload.rs`
+- Modify: `src/plugins/registry.rs`
+- Modify: `src/plugins/runtime.rs`
+- Modify: `src/main.rs`
+- Test: `src/plugins/hot_reload.rs` inline tests
+
+- Step 1: Write the failing test
+
+```rust
+#[tokio::test]
+async fn reload_failure_keeps_previous_snapshot_active() {}
+```
+
+- Step 2: Run test to verify it fails Run:
+`cargo test --locked reload_failure_keeps_previous_snapshot_active -- --nocapture` Expected: FAIL
+before atomic swap logic.
+
+- Step 3: Write minimal implementation
+
+- File watcher rebuilds candidate snapshot.
+- Validate fully before publish.
+- Atomic swap on success; rollback on failure.
+- Preserve in-flight snapshot handles.
+
+- Step 4: Run test to verify it passes Run:
+`cargo test --locked plugins::hot_reload -- --nocapture` Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add src/plugins/hot_reload.rs src/plugins/registry.rs src/plugins/runtime.rs src/main.rs
+git commit -m "feat(plugins): add safe hot-reload with immutable snapshot swap"
+```
+
+## Task 10: Documentation and verification pass
+
+**Files:**
+
+- Create: `docs/plugins-runtime.md`
+- Modify: `docs/config-reference.md`
+- Modify: `docs/commands-reference.md`
+- Modify: `docs/troubleshooting.md`
+- Modify: locale docs where equivalents exist (`fr`, `vi` minimum for
+ config/commands/troubleshooting)
+
+- Step 1: Write the failing doc checks
+
+- Define link/consistency checks and navigation parity expectations.
+
+- Step 2: Run doc checks to verify failures (if stale links exist) Run: project markdown/link
+checks used in repo CI. Expected: potential FAIL until docs updated.
+
+- Step 3: Write minimal documentation updates
+
+- Plugin config keys, lifecycle, safety model, hot reload behavior, operator troubleshooting.
+
+- Step 4: Run full validation Run:
+
+```bash
+cargo fmt --all -- --check
+cargo clippy --all-targets -- -D warnings
+cargo test --locked
+```
+
+Expected: PASS.
+
+- Step 5: Commit
+
+```bash
+git add docs src
+git commit -m "docs(plugins): document wasm plugin runtime config lifecycle and operations"
+```
+
+## Final Integration Checklist
+
+- Ensure plugins disabled mode preserves existing behavior.
+- Ensure security defaults remain deny-by-default.
+- Ensure hook ordering and cancellation semantics are deterministic.
+- Ensure provider/tool fallback behavior is unchanged for native implementations.
+- Ensure hot-reload failures are non-fatal and reversible.
diff --git a/docs/plugins-runtime.md b/docs/plugins-runtime.md
new file mode 100644
index 000000000..24b81200a
--- /dev/null
+++ b/docs/plugins-runtime.md
@@ -0,0 +1,135 @@
+# WASM Plugin Runtime (Experimental)
+
+This document describes the current experimental plugin runtime for ZeroClaw.
+
+## Scope
+
+Current implementation supports:
+
+- plugin manifest discovery from `[plugins].load_paths`
+- plugin-declared tool registration into tool specs
+- plugin-declared provider registration into provider factory resolution
+- host-side WASM invocation bridge for tool/provider calls
+- manifest fingerprint tracking scaffolding (hot-reload toggle is not yet exposed in schema)
+
+## Config
+
+```toml
+[plugins]
+enabled = true
+load_paths = ["plugins"]
+allow = []
+deny = []
+```
+
+Defaults are deny-by-default and disabled-by-default.
+Execution limits are currently conservative fixed defaults in runtime code:
+
+- `invoke_timeout_ms = 2000`
+- `memory_limit_bytes = 67108864`
+- `max_concurrency = 8`
+
+## Manifest Files
+
+The runtime scans each configured directory for:
+
+- `*.plugin.toml`
+- `*.plugin.json`
+
+Minimal TOML example:
+
+```toml
+id = "demo"
+version = "1.0.0"
+module_path = "plugins/demo.wasm"
+wit_packages = ["zeroclaw:tools@1.0.0", "zeroclaw:providers@1.0.0"]
+
+[[tools]]
+name = "demo_tool"
+description = "Demo tool"
+
+providers = ["demo-provider"]
+```
+
+## WIT Package Compatibility
+
+Supported package majors:
+
+- `zeroclaw:hooks@1.x`
+- `zeroclaw:tools@1.x`
+- `zeroclaw:providers@1.x`
+
+Unknown packages or mismatched major versions are rejected during manifest load.
+
+## WASM Host ABI (Current Bridge)
+
+The current bridge calls core-WASM exports directly.
+
+Required exports:
+
+- `memory`
+- `alloc(i32) -> i32`
+- `dealloc(i32, i32)`
+- `zeroclaw_tool_execute(i32, i32) -> i64`
+- `zeroclaw_provider_chat(i32, i32) -> i64`
+
+Conventions:
+
+- Input is UTF-8 JSON written by host into guest memory.
+- Return value packs output pointer/length into `i64`:
+ - high 32 bits: pointer
+ - low 32 bits: length
+- Host reads UTF-8 output JSON/string and deallocates buffers.
+
+Tool call payload shape:
+
+```json
+{
+ "tool": "demo_tool",
+ "args": { "key": "value" }
+}
+```
+
+Provider call payload shape:
+
+```json
+{
+ "provider": "demo-provider",
+ "system_prompt": "optional",
+ "message": "user prompt",
+ "model": "model-name",
+ "temperature": 0.7
+}
+```
+
+Provider output may be either plain text or JSON:
+
+```json
+{
+ "text": "response text",
+ "error": null
+}
+```
+
+If `error` is non-null, host treats the call as failed.
+
+## Hot Reload
+
+Manifest fingerprints are tracked internally, but the config schema does not currently expose a
+`[plugins].hot_reload` toggle. Runtime hot-reload remains disabled by default until that schema
+support is added.
+
+## Observer Bridge
+
+Observer creation paths route through `ObserverBridge` to keep plugin runtime event flow compatible
+with existing observer backends.
+
+## Limitations
+
+Current bridge is intentionally minimal:
+
+- no full WIT component-model host bindings yet
+- no per-plugin sandbox isolation beyond process/runtime defaults
+- no signature verification or trust policy enforcement yet
+- tool/provider manifests define registration; execution ABI is currently fixed to the core-WASM
+ export contract above
diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md
index 30a230e8c..eab401d9a 100644
--- a/docs/pr-workflow.md
+++ b/docs/pr-workflow.md
@@ -96,12 +96,16 @@ Automation assists with triage and guardrails, but final merge accountability re
Maintain these branch protection rules on `dev` and `main`:
- Require status checks before merge.
-- Require check `CI Required Gate`.
+- Require checks `CI Required Gate` and `Security Required Gate`.
+- Consider also requiring `CI Change Audit` and `CodeQL Analysis` for stricter CI/CD governance.
- Require pull request reviews before merge.
+- Require at least 1 approving review.
+- Require approval after the most recent push.
- Require CODEOWNERS review for protected paths.
-- For CI/CD-related paths (`.github/workflows/**`, `.github/codeql/**`, `.github/connectivity/**`, `.github/release/**`, `.github/security/**`, `.github/actionlint.yaml`, `.github/dependabot.yml`, `scripts/ci/**`, and CI governance docs), require an explicit approving review from `@chumyin` via `CI Required Gate`.
-- Keep branch/ruleset bypass limited to org owners.
-- Dismiss stale approvals when new commits are pushed.
+- For CI/CD-related paths (`.github/workflows/**`, `.github/codeql/**`, `.github/connectivity/**`, `.github/release/**`, `.github/security/**`, `.github/actionlint.yaml`, `.github/dependabot.yml`, `scripts/ci/**`, and CI governance docs), require CODEOWNERS review with `@chumyin` ownership.
+- Keep bypass allowances empty by default (use time-boxed break-glass only when absolutely required).
+- Enforce branch protection for admins.
+- Require conversation resolution before merge.
- Restrict force-push on protected branches.
- Route normal contributor PRs to `main` by default (`dev` is optional for dedicated integration batching).
- Allow direct merges to `main` once required checks and review policy pass.
@@ -123,7 +127,7 @@ Maintain these branch protection rules on `dev` and `main`:
### 4.2 Step B: Validation
-- `CI Required Gate` is the merge gate.
+- `CI Required Gate` and `Security Required Gate` are the merge gates.
- Docs-only PRs use fast-path and skip heavy Rust jobs.
- Non-doc PRs must pass lint, tests, and release build smoke check.
- Rust-impacting PRs use the same required gate set as `dev`/`main` pushes (no PR build-only shortcut).
diff --git a/docs/project/README.md b/docs/project/README.md
index a2238ed5a..712ff3501 100644
--- a/docs/project/README.md
+++ b/docs/project/README.md
@@ -7,6 +7,8 @@ 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)
+- [f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md](f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md)
+- [q0-3-stop-reason-state-machine-rfi-2026-03-01.md](q0-3-stop-reason-state-machine-rfi-2026-03-01.md)
## Scope
diff --git a/docs/project/f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md b/docs/project/f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md
new file mode 100644
index 000000000..69fd96bc2
--- /dev/null
+++ b/docs/project/f1-3-agent-lifecycle-state-machine-rfi-2026-03-01.md
@@ -0,0 +1,193 @@
+# F1-3 Agent Lifecycle State Machine RFI (2026-03-01)
+
+Status: RFI complete, implementation planning ready.
+GitHub issue: [#2308](https://github.com/zeroclaw-labs/zeroclaw/issues/2308)
+Linear: [RMN-256](https://linear.app/zeroclawlabs/issue/RMN-256/rfi-f1-3-agent-lifecycle-state-machine)
+
+## Summary
+
+ZeroClaw currently has strong component supervision and health snapshots, but it does not expose a
+formal agent lifecycle state model. This RFI defines a lifecycle FSM, transition contract,
+synchronization model, persistence posture, and migration path that can be implemented without
+changing existing daemon reliability behavior.
+
+## Current-State Findings
+
+### Existing behavior that already works
+
+- `src/daemon/mod.rs` supervises gateway/channels/heartbeat/scheduler with restart backoff.
+- `src/health/mod.rs` tracks per-component `status`, `last_ok`, `last_error`, and `restart_count`.
+- `src/agent/session.rs` persists conversational history with memory/SQLite backends and TTL cleanup.
+- `src/agent/loop_.rs` and `src/agent/agent.rs` provide bounded per-turn execution loops.
+
+### Gaps blocking lifecycle consistency
+
+- No typed lifecycle enum for the agent runtime (or per-session runtime state).
+- No validated transition guard rails (invalid transitions are not prevented centrally).
+- Health state and lifecycle state are conflated (`ok`/`error` are not full lifecycle semantics).
+- Persistence only covers health snapshots and conversation history, not lifecycle transitions.
+- No single integration contract for daemon, channels, supervisor, and health endpoint consumers.
+
+## Proposed Lifecycle Model
+
+### State definitions
+
+- `Created`: runtime object exists but not started.
+- `Starting`: dependencies are being initialized.
+- `Running`: normal operation, accepting and processing work.
+- `Degraded`: still running but with elevated failure/restart signals.
+- `Suspended`: intentionally paused (manual pause, e-stop, or maintenance gate).
+- `Backoff`: recovering after crash/error; restart cooldown active.
+- `Terminating`: graceful shutdown in progress.
+- `Terminated`: clean shutdown completed.
+- `Crashed`: unrecoverable failure after retry budget is exhausted.
+
+### State diagram
+
+```mermaid
+stateDiagram-v2
+ [*] --> Created
+ Created --> Starting: daemon run/start
+ Starting --> Running: init_ok
+ Starting --> Backoff: init_fail
+ Running --> Degraded: component_error_threshold
+ Degraded --> Running: recovered
+ Running --> Suspended: pause_or_estop
+ Degraded --> Suspended: pause_or_estop
+ Suspended --> Running: resume
+ Backoff --> Starting: retry_after_backoff
+ Backoff --> Crashed: retry_budget_exhausted
+ Running --> Terminating: shutdown_signal
+ Degraded --> Terminating: shutdown_signal
+ Suspended --> Terminating: shutdown_signal
+ Terminating --> Terminated: shutdown_complete
+ Crashed --> Terminating: manual_stop
+```
+
+### Transition table
+
+| From | Trigger | Guard | To | Action |
+|---|---|---|---|---|
+| `Created` | daemon start | config valid | `Starting` | emit lifecycle event |
+| `Starting` | init success | all required components healthy | `Running` | clear restart streak |
+| `Starting` | init failure | retry budget available | `Backoff` | increment restart streak |
+| `Running` | component errors | restart streak >= threshold | `Degraded` | set degraded cause |
+| `Degraded` | recovery success | error window clears | `Running` | clear degraded cause |
+| `Running`/`Degraded` | pause/e-stop | operator or policy signal | `Suspended` | stop intake/execution |
+| `Suspended` | resume | policy allows | `Running` | re-enable intake |
+| `Backoff` | retry timer | retry budget available | `Starting` | start component init |
+| `Backoff` | retry exhausted | no retries left | `Crashed` | emit terminal failure event |
+| non-terminal states | shutdown | signal received | `Terminating` | drain and stop workers |
+| `Terminating` | done | all workers stopped | `Terminated` | persist final snapshot |
+
+## Implementation Approach
+
+### State representation
+
+Add a dedicated lifecycle type in runtime/daemon scope:
+
+```rust
+enum AgentLifecycleState {
+ Created,
+ Starting,
+ Running,
+ Degraded { cause: String },
+ Suspended { reason: String },
+ Backoff { retry_in_ms: u64, attempt: u32 },
+ Terminating,
+ Terminated,
+ Crashed { reason: String },
+}
+```
+
+### Synchronization model
+
+- Use a single `LifecycleRegistry` (`Arc>`) owned by daemon runtime.
+- Route all lifecycle writes through `transition(from, to, trigger)` with guard checks.
+- Emit transition events from one place, then fan out to health snapshot and observability.
+- Reject invalid transitions at runtime and log them as policy violations.
+
+## Persistence Decision
+
+Decision: **hybrid persistence**.
+
+- Runtime source of truth: in-memory lifecycle registry for low-latency transitions.
+- Durable checkpoint: persisted lifecycle snapshot alongside `daemon_state.json`.
+- Optional append-only transition journal (`lifecycle_events.jsonl`) for audit and forensics.
+
+Rationale:
+
+- In-memory state keeps current daemon behavior fast and simple.
+- Persistent checkpoint enables status restoration after restart and improves operator clarity.
+- Event journal is valuable for post-incident analysis without changing runtime control flow.
+
+## Integration Points
+
+- `src/daemon/mod.rs`
+ - wrap supervisor start/failure/backoff/shutdown with explicit lifecycle transitions.
+- `src/health/mod.rs`
+ - expose lifecycle state in health snapshot without replacing component-level health detail.
+- `src/main.rs` (`status`, `restart`, e-stop surfaces)
+ - render lifecycle state and transition reason in CLI output.
+- `src/channels/mod.rs` and channel workers
+ - gate message intake when lifecycle is `Suspended`, `Terminating`, `Crashed`, or `Terminated`.
+- `src/agent/session.rs`
+ - keep session history semantics unchanged; add optional link from session to runtime lifecycle id.
+
+## Migration Plan
+
+### Phase 1: Non-breaking state plumbing
+
+- Add lifecycle enum/registry and default transitions in daemon startup/shutdown.
+- Include lifecycle state in health JSON output.
+- Keep existing component health fields unchanged.
+
+### Phase 2: Supervisor transition wiring
+
+- Convert supervisor restart/error signals into lifecycle transitions.
+- Add backoff metadata (`attempt`, `retry_in_ms`) to lifecycle snapshots.
+
+### Phase 3: Intake gating + operator controls
+
+- Enforce channel/gateway intake gating by lifecycle state.
+- Surface lifecycle controls and richer status output in CLI.
+
+### Phase 4: Persistence + event journal
+
+- Persist snapshot and optional JSONL transition events.
+- Add recovery behavior for daemon restart from persisted snapshot.
+
+## Verification and Testing Plan
+
+### Unit tests
+
+- transition guard tests for all valid/invalid state pairs.
+- lifecycle-to-health serialization tests.
+- persistence round-trip tests for snapshot and event journal.
+
+### Integration tests
+
+- daemon startup failure -> backoff -> recovery path.
+- repeated failure -> `Crashed` transition.
+- suspend/resume behavior for channel intake and scheduler activity.
+
+### Chaos/failure tests
+
+- component panic/exit simulation under supervisor.
+- rapid restart storm protection and state consistency checks.
+
+## Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+|---|---|---|
+| Overlap between health and lifecycle semantics | Operator confusion | Keep both domains explicit and documented |
+| Invalid transition bugs during rollout | Runtime inconsistency | Central transition API with guard checks |
+| Excessive persistence I/O | Throughput impact | snapshot throttling + async event writes |
+| Channel behavior regressions on suspend | Message loss | add intake gating tests and dry-run mode |
+
+## Implementation Readiness Checklist
+
+- [x] State diagram and transition table documented.
+- [x] State representation and synchronization approach selected.
+- [x] Persistence strategy documented.
+- [x] Integration points and migration plan documented.
diff --git a/docs/project/q0-3-stop-reason-state-machine-rfi-2026-03-01.md b/docs/project/q0-3-stop-reason-state-machine-rfi-2026-03-01.md
new file mode 100644
index 000000000..b85301896
--- /dev/null
+++ b/docs/project/q0-3-stop-reason-state-machine-rfi-2026-03-01.md
@@ -0,0 +1,222 @@
+# Q0-3 Stop-Reason State Machine + Max-Tokens Continuation RFI (2026-03-01)
+
+Status: RFI complete, implementation planning ready.
+GitHub issue: [#2309](https://github.com/zeroclaw-labs/zeroclaw/issues/2309)
+Linear: [RMN-257](https://linear.app/zeroclawlabs/issue/RMN-257/rfi-q0-3-stop-reason-state-machine-max-tokens-continuation)
+
+## Summary
+
+ZeroClaw currently parses text/tool calls and token usage across providers, but it does not carry a
+normalized stop reason into `ChatResponse`, and there is no deterministic continuation loop for
+`max_tokens` truncation. This RFI defines a provider mapping model, a continuation FSM, partial
+tool-call recovery policy, and observability/testing requirements.
+
+## Current-State Findings
+
+### Confirmed implementation behavior
+
+- `src/providers/traits.rs` `ChatResponse` has no stop-reason field.
+- Provider adapters parse text/tool-calls/usage, but stop reason fields are mostly discarded.
+- `src/agent/loop_.rs` finalizes response if no parsed tool calls are present.
+- Existing parser in `src/agent/loop_/parsing.rs` already handles many malformed/truncated
+ tool-call formats safely (no panic), but this is parsing recovery, not continuation policy.
+
+### Known gap
+
+- When a provider truncates output due to max token cap, the loop lacks a dedicated continuation
+ path. Result: partial responses can be returned silently.
+
+## Proposed Stop-Reason Model
+
+### Normalized enum
+
+```rust
+enum NormalizedStopReason {
+ EndTurn,
+ ToolCall,
+ MaxTokens,
+ ContextWindowExceeded,
+ SafetyBlocked,
+ Cancelled,
+ Unknown(String),
+}
+```
+
+### `ChatResponse` extension
+
+Add stop-reason payload to provider response contract:
+
+```rust
+pub struct ChatResponse {
+ pub text: Option,
+ pub tool_calls: Vec,
+ pub usage: Option,
+ pub reasoning_content: Option,
+ pub quota_metadata: Option,
+ pub stop_reason: Option,
+ pub raw_stop_reason: Option,
+}
+```
+
+`raw_stop_reason` preserves provider-native values for diagnostics and future mapping updates.
+
+## Provider Mapping Matrix
+
+This table defines implementation targets for active provider families in ZeroClaw.
+
+| Provider family | Native field | Native values | Normalized |
+|---|---|---|---|
+| OpenAI / OpenRouter / OpenAI-compatible chat | `finish_reason` | `stop` | `EndTurn` |
+| OpenAI / OpenRouter / OpenAI-compatible chat | `finish_reason` | `tool_calls`, `function_call` | `ToolCall` |
+| OpenAI / OpenRouter / OpenAI-compatible chat | `finish_reason` | `length` | `MaxTokens` |
+| OpenAI / OpenRouter / OpenAI-compatible chat | `finish_reason` | `content_filter` | `SafetyBlocked` |
+| Anthropic messages | `stop_reason` | `end_turn`, `stop_sequence` | `EndTurn` |
+| Anthropic messages | `stop_reason` | `tool_use` | `ToolCall` |
+| Anthropic messages | `stop_reason` | `max_tokens` | `MaxTokens` |
+| Anthropic messages | `stop_reason` | `model_context_window_exceeded` | `ContextWindowExceeded` |
+| Gemini generateContent | `finishReason` | `STOP` | `EndTurn` |
+| Gemini generateContent | `finishReason` | `MAX_TOKENS` | `MaxTokens` |
+| Gemini generateContent | `finishReason` | `SAFETY`, `RECITATION` | `SafetyBlocked` |
+| Bedrock Converse | `stopReason` | `end_turn` | `EndTurn` |
+| Bedrock Converse | `stopReason` | `tool_use` | `ToolCall` |
+| Bedrock Converse | `stopReason` | `max_tokens` | `MaxTokens` |
+| Bedrock Converse | `stopReason` | `guardrail_intervened` | `SafetyBlocked` |
+
+Notes:
+
+- Unknown values map to `Unknown(raw)` and must be logged once per provider/model combination.
+- Mapping must be unit-tested against fixture payloads for each provider adapter.
+
+## Continuation State Machine
+
+### Goals
+
+- Continue only when stop reason indicates output truncation.
+- Bound retries and total output growth.
+- Preserve tool-call correctness (never execute partial JSON).
+
+### State diagram
+
+```mermaid
+stateDiagram-v2
+ [*] --> Request
+ Request --> EvaluateStop: provider_response
+ EvaluateStop --> Complete: EndTurn
+ EvaluateStop --> ExecuteTools: ToolCall
+ EvaluateStop --> ContinuePending: MaxTokens
+ EvaluateStop --> Abort: SafetyBlocked/ContextWindowExceeded/UnknownFatal
+ ContinuePending --> RequestContinuation: under_limits
+ RequestContinuation --> EvaluateStop: provider_response
+ ContinuePending --> AbortPartial: retry_limit_or_budget_exceeded
+ AbortPartial --> Complete: return_partial_with_notice
+ ExecuteTools --> Request: tool_results_appended
+```
+
+### Hard limits (defaults)
+
+- `max_continuations_per_turn = 3`
+- `max_total_completion_tokens_per_turn = 4 * initial_max_tokens` (configurable)
+- `max_total_output_chars_per_turn = 120_000` (safety cap)
+
+## Partial Tool-Call JSON Policy
+
+### Rules
+
+- Never execute tool calls when parsed payload is incomplete/ambiguous.
+- If `MaxTokens` and parser detects malformed/partial tool-call body:
+ - request deterministic re-emission of the tool call payload only.
+ - keep attempt budget separate (`max_tool_repair_attempts = 1`).
+- If repair fails, degrade safely:
+ - return a partial response with explicit truncation notice.
+ - emit structured event for operator diagnosis.
+
+### Recovery prompt contract
+
+Use a strict system-side continuation hint:
+
+```text
+Previous response was truncated by token limit.
+Continue exactly from where you left off.
+If you intended a tool call, emit one complete tool call payload only.
+Do not repeat already-sent text.
+```
+
+## Observability Requirements
+
+Emit structured events per turn:
+
+- `stop_reason_observed`
+ - provider, model, normalized reason, raw reason, turn id, iteration.
+- `continuation_attempt`
+ - attempt index, cumulative output tokens/chars, budget remaining.
+- `continuation_terminated`
+ - terminal reason (`completed`, `retry_limit`, `budget_exhausted`, `safety_blocked`).
+- `tool_payload_repair`
+ - parse issue type, repair attempted, repair success/failure.
+
+Metrics:
+
+- counter: continuations triggered by provider/model.
+- counter: truncation exits without continuation (guardrail/budget cases).
+- histogram: continuation attempts per turn.
+- histogram: end-to-end turn latency for continued turns.
+
+## Implementation Outline
+
+### Provider layer
+
+- Parse and map native stop reason fields in each adapter.
+- Populate `stop_reason` and `raw_stop_reason` in `ChatResponse`.
+- Add fixture-based unit tests for mapping.
+
+### Agent loop layer
+
+- Introduce `ContinuationController` in `src/agent/loop_.rs`.
+- Route `MaxTokens` through continuation FSM before finalization.
+- Merge continuation text chunks into one coherent assistant response.
+- Keep existing tool parsing and loop-detection guards intact.
+
+### Config layer
+
+Add config keys under `agent`:
+
+- `continuation_max_attempts`
+- `continuation_max_output_chars`
+- `continuation_max_total_completion_tokens`
+- `continuation_tool_repair_attempts`
+
+## Verification and Testing Plan
+
+### Unit tests
+
+- stop-reason mapping tests per provider adapter.
+- continuation FSM transition tests (all terminal paths).
+- budget cap tests and retry-limit behavior.
+
+### Integration tests
+
+- mock provider returns `MaxTokens` then successful continuation.
+- mock provider returns repeated `MaxTokens` until retry cap.
+- mock provider emits partial tool-call JSON then repaired payload.
+
+### Regression tests
+
+- ensure non-truncated normal responses are unchanged.
+- ensure existing parser recovery tests in `loop_/parsing.rs` remain green.
+- verify no duplicate text when continuation merges.
+
+## Risks and Mitigations
+
+| Risk | Impact | Mitigation |
+|---|---|---|
+| Provider mapping drift | incorrect continuation triggers | keep `raw_stop_reason` + tests |
+| Continuation repetition loops | poor UX, extra tokens | dedupe heuristics + strict caps |
+| Partial tool-call execution | unsafe tool behavior | hard block on malformed payload |
+| Latency growth | slower responses | cap attempts and emit metrics |
+
+## Implementation Readiness Checklist
+
+- [x] Provider stop-reason mapping documented.
+- [x] Continuation policy and hard limits documented.
+- [x] Partial tool-call handling strategy documented.
+- [x] Proposed state machine documented for implementation.
diff --git a/docs/providers-reference.md b/docs/providers-reference.md
index 1a490422e..ab41d6352 100644
--- a/docs/providers-reference.md
+++ b/docs/providers-reference.md
@@ -2,7 +2,7 @@
This document maps provider IDs, aliases, and credential environment variables.
-Last verified: **February 28, 2026**.
+Last verified: **March 1, 2026**.
## How to List Providers
@@ -35,6 +35,7 @@ credential is not reused for fallback providers.
| `vercel` | `vercel-ai` | No | `VERCEL_API_KEY` |
| `cloudflare` | `cloudflare-ai` | No | `CLOUDFLARE_API_KEY` |
| `moonshot` | `kimi` | No | `MOONSHOT_API_KEY` |
+| `stepfun` | `step`, `step-ai`, `step_ai` | No | `STEP_API_KEY`, `STEPFUN_API_KEY` |
| `kimi-code` | `kimi_coding`, `kimi_for_coding` | No | `KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY` |
| `synthetic` | — | No | `SYNTHETIC_API_KEY` |
| `opencode` | `opencode-zen` | No | `OPENCODE_API_KEY` |
@@ -137,6 +138,33 @@ zeroclaw models refresh --provider volcengine
zeroclaw agent --provider volcengine --model doubao-1-5-pro-32k-250115 -m "ping"
```
+### StepFun Notes
+
+- Provider ID: `stepfun` (aliases: `step`, `step-ai`, `step_ai`)
+- Base API URL: `https://api.stepfun.com/v1`
+- Chat endpoint: `/chat/completions`
+- Model discovery endpoint: `/models`
+- Authentication: `STEP_API_KEY` (fallback: `STEPFUN_API_KEY`)
+- Default model preset: `step-3.5-flash`
+- Official docs:
+ - Chat Completions:
+ - Models List:
+ - OpenAI migration guide:
+
+Minimal setup example:
+
+```bash
+export STEP_API_KEY="your-stepfun-api-key"
+zeroclaw onboard --provider stepfun --api-key "$STEP_API_KEY" --model step-3.5-flash --force
+```
+
+Quick validation:
+
+```bash
+zeroclaw models refresh --provider stepfun
+zeroclaw agent --provider stepfun --model step-3.5-flash -m "ping"
+```
+
### SiliconFlow Notes
- Provider ID: `siliconflow` (aliases: `silicon-cloud`, `siliconcloud`)
diff --git a/docs/proxy-agent-playbook.md b/docs/proxy-agent-playbook.md
index 5e1cbefff..0d4f2cca5 100644
--- a/docs/proxy-agent-playbook.md
+++ b/docs/proxy-agent-playbook.md
@@ -113,14 +113,14 @@ Use when only part of the system should use proxy (for example specific provider
### 5.1 Target specific services
```json
-{"action":"set","enabled":true,"scope":"services","services":["provider.openai","tool.http_request","channel.telegram"],"all_proxy":"socks5h://127.0.0.1:1080","no_proxy":["localhost","127.0.0.1",".internal"]}
+{"action":"set","enabled":true,"scope":"services","services":["provider.openai","tool.multimodal","tool.http_request","channel.telegram"],"all_proxy":"socks5h://127.0.0.1:1080","no_proxy":["localhost","127.0.0.1",".internal"]}
{"action":"get"}
```
### 5.2 Target by selectors
```json
-{"action":"set","enabled":true,"scope":"services","services":["provider.*","tool.*"],"http_proxy":"http://127.0.0.1:7890"}
+{"action":"set","enabled":true,"scope":"services","services":["provider.*","tool.*","channel.qq"],"http_proxy":"http://127.0.0.1:7890"}
{"action":"get"}
```
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index c72826fee..104b0907e 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -2,7 +2,7 @@
This guide focuses on common setup/runtime failures and fast resolution paths.
-Last verified: **February 20, 2026**.
+Last verified: **March 2, 2026**.
## Installation / Bootstrap
@@ -142,6 +142,54 @@ Persist in your shell profile if needed.
## Runtime / Gateway
+### Windows: shell tool unavailable or repeated shell failures
+
+Symptoms:
+
+- agent repeatedly fails shell calls and stops early
+- shell-based actions fail even though ZeroClaw starts
+- `zeroclaw doctor` reports runtime shell capability unavailable
+
+Why this happens:
+
+- Native Windows shell availability differs by machine setup.
+- Some environments do not have `sh` in `PATH`.
+- If both Git Bash and PowerShell are missing/misconfigured, shell tool execution will fail.
+
+What changed in ZeroClaw:
+
+- Native runtime now resolves shell with Windows fallbacks in this order:
+ - `bash` -> `sh` -> `pwsh` -> `powershell` -> `cmd`/`COMSPEC`
+- `zeroclaw doctor` now reports:
+ - selected native shell (kind + resolved executable path)
+ - candidate shell availability on Windows
+ - explicit warning when fallback is only `cmd`
+- WSL2 is optional, not required.
+
+Checks (PowerShell):
+
+```powershell
+where.exe bash
+where.exe pwsh
+where.exe powershell
+echo $env:COMSPEC
+zeroclaw doctor
+```
+
+Fix:
+
+1. Install at least one preferred shell:
+ - Git Bash (recommended for Unix-like command compatibility), or
+ - PowerShell 7 (`pwsh`)
+2. Confirm the shell executable is available in `PATH`.
+3. Ensure `COMSPEC` is set (normally points to `cmd.exe` on Windows).
+4. Reopen terminal and rerun `zeroclaw doctor`.
+
+Notes:
+
+- Running with only `cmd` fallback can work, but compatibility is lower than Git Bash or PowerShell.
+- If you already use WSL2, it can help with Unix-style workflows, but it is not mandatory for ZeroClaw shell tooling.
+
### Gateway unreachable
Checks:
@@ -306,16 +354,61 @@ Linux logs:
journalctl --user -u zeroclaw.service -f
```
+## macOS Catalina (10.15) Compatibility
+
+### Build or run fails on macOS Catalina
+
+Symptoms:
+
+- `cargo build` fails with linker errors referencing a minimum deployment target higher than 10.15
+- Binary exits immediately or crashes with `Illegal instruction: 4` on launch
+- Error message references `macOS 11.0` or `Big Sur` as a requirement
+
+Why this happens:
+
+- `wasmtime` (the WASM plugin engine used by the `wasm-tools` feature) uses Cranelift JIT
+ compilation, which has macOS version dependencies that may exceed Catalina (10.15).
+- If your Rust toolchain was installed or updated on a newer macOS host, the default
+ `MACOSX_DEPLOYMENT_TARGET` may be set higher than 10.15, producing binaries that refuse
+ to start on Catalina.
+
+Fix — build without the WASM plugin engine (recommended on Catalina):
+
+```bash
+cargo build --release --locked
+```
+
+The default feature set no longer includes `wasm-tools`, so the above command produces a
+Catalina-compatible binary without Cranelift/JIT dependencies.
+
+If you need WASM plugin support and are on a newer macOS (11.0+), opt in explicitly:
+
+```bash
+cargo build --release --locked --features wasm-tools
+```
+
+Fix — explicit deployment target (belt-and-suspenders):
+
+If you still see deployment-target linker errors, set the target explicitly before building:
+
+```bash
+MACOSX_DEPLOYMENT_TARGET=10.15 cargo build --release --locked
+```
+
+The `.cargo/config.toml` in this repository already pins `x86_64-apple-darwin` builds to
+`-mmacosx-version-min=10.15`, so the environment variable is usually not required.
+
## Legacy Installer Compatibility
Both still work:
```bash
-curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/bootstrap.sh | bash
+curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/scripts/install.sh | bash
```
-`install.sh` is a compatibility entry and forwards/falls back to bootstrap behavior.
+Root `install.sh` is the canonical remote entrypoint and defaults to TUI onboarding for no-arg interactive sessions.
+`scripts/install.sh` remains a compatibility entry and forwards/falls back to bootstrap behavior.
## Still Stuck?
diff --git a/examples/plugins/echo/README.md b/examples/plugins/echo/README.md
new file mode 100644
index 000000000..1f96e715a
--- /dev/null
+++ b/examples/plugins/echo/README.md
@@ -0,0 +1,39 @@
+# Echo Plugin Example
+
+This folder contains a minimal plugin manifest and a WAT template matching the current host ABI.
+
+Files:
+- `echo.plugin.toml` - plugin declaration loaded by ZeroClaw
+- `echo.wat` - sample WASM text source
+
+## Build
+
+Convert WAT to WASM with `wat2wasm`:
+
+```bash
+wat2wasm examples/plugins/echo/echo.wat -o examples/plugins/echo/echo.wasm
+```
+
+## Enable in config
+
+```toml
+[plugins]
+enabled = true
+load_paths = ["examples/plugins/echo"]
+```
+
+## ABI exports required
+
+- `memory`
+- `alloc(i32) -> i32`
+- `dealloc(i32, i32)`
+- `zeroclaw_tool_execute(i32, i32) -> i64`
+- `zeroclaw_provider_chat(i32, i32) -> i64`
+
+The `i64` return packs output pointer/length:
+- high 32 bits: pointer
+- low 32 bits: length
+
+Input/output payloads are UTF-8 JSON.
+
+Note: this example intentionally keeps logic minimal and is not production-safe.
diff --git a/examples/plugins/echo/echo.plugin.toml b/examples/plugins/echo/echo.plugin.toml
new file mode 100644
index 000000000..33cfb5daa
--- /dev/null
+++ b/examples/plugins/echo/echo.plugin.toml
@@ -0,0 +1,10 @@
+id = "echo"
+version = "1.0.0"
+module_path = "examples/plugins/echo/echo.wasm"
+wit_packages = ["zeroclaw:tools@1.0.0", "zeroclaw:providers@1.0.0"]
+
+[[tools]]
+name = "echo_tool"
+description = "Return the incoming tool payload as text"
+
+providers = ["echo-provider"]
diff --git a/examples/plugins/echo/echo.wat b/examples/plugins/echo/echo.wat
new file mode 100644
index 000000000..5c32a7a04
--- /dev/null
+++ b/examples/plugins/echo/echo.wat
@@ -0,0 +1,43 @@
+(module
+ (memory (export "memory") 1)
+ (global $heap (mut i32) (i32.const 1024))
+
+ ;; ABI: alloc(len) -> ptr
+ (func (export "alloc") (param $len i32) (result i32)
+ (local $ptr i32)
+ global.get $heap
+ local.set $ptr
+ global.get $heap
+ local.get $len
+ i32.add
+ global.set $heap
+ local.get $ptr
+ )
+
+ ;; ABI: dealloc(ptr, len) -> ()
+ ;; no-op bump allocator example
+ (func (export "dealloc") (param $ptr i32) (param $len i32))
+
+ ;; Writes a static response into memory and returns packed ptr/len in i64.
+ (func $write_static_response (param $src i32) (param $len i32) (result i64)
+ (local $out_ptr i32)
+ ;; output text: "ok"
+ (local.set $out_ptr (call 0 (i32.const 2)))
+ (i32.store8 (i32.add (local.get $out_ptr) (i32.const 0)) (i32.const 111))
+ (i32.store8 (i32.add (local.get $out_ptr) (i32.const 1)) (i32.const 107))
+ (i64.or
+ (i64.shl (i64.extend_i32_u (local.get $out_ptr)) (i64.const 32))
+ (i64.extend_i32_u (i32.const 2))
+ )
+ )
+
+ ;; ABI: zeroclaw_tool_execute(input_ptr, input_len) -> packed ptr/len i64
+ (func (export "zeroclaw_tool_execute") (param $ptr i32) (param $len i32) (result i64)
+ (call $write_static_response (local.get $ptr) (local.get $len))
+ )
+
+ ;; ABI: zeroclaw_provider_chat(input_ptr, input_len) -> packed ptr/len i64
+ (func (export "zeroclaw_provider_chat") (param $ptr i32) (param $len i32) (result i64)
+ (call $write_static_response (local.get $ptr) (local.get $len))
+ )
+)
diff --git a/install.sh b/install.sh
new file mode 100755
index 000000000..453d63b15
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Canonical remote installer entrypoint.
+# Default behavior for no-arg interactive shells is TUI onboarding.
+
+BOOTSTRAP_URL="${ZEROCLAW_BOOTSTRAP_URL:-https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/refs/heads/main/scripts/bootstrap.sh}"
+
+have_cmd() {
+ command -v "$1" >/dev/null 2>&1
+}
+
+run_remote_bootstrap() {
+ local -a args=("$@")
+
+ if have_cmd curl; then
+ if [[ ${#args[@]} -eq 0 ]]; then
+ curl -fsSL "$BOOTSTRAP_URL" | bash
+ else
+ curl -fsSL "$BOOTSTRAP_URL" | bash -s -- "${args[@]}"
+ fi
+ return 0
+ fi
+
+ if have_cmd wget; then
+ if [[ ${#args[@]} -eq 0 ]]; then
+ wget -qO- "$BOOTSTRAP_URL" | bash
+ else
+ wget -qO- "$BOOTSTRAP_URL" | bash -s -- "${args[@]}"
+ fi
+ return 0
+ fi
+
+ echo "error: curl or wget is required to run remote installer bootstrap." >&2
+ return 1
+}
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd || pwd)"
+LOCAL_INSTALLER="$SCRIPT_DIR/zeroclaw_install.sh"
+
+declare -a FORWARDED_ARGS=("$@")
+# In piped one-liners (`curl ... | bash`) stdin is not a TTY; prefer the
+# controlling terminal when available so interactive onboarding is still default.
+if [[ $# -eq 0 && -t 1 ]] && (: /dev/null; then
+ FORWARDED_ARGS=(--interactive-onboard)
+fi
+
+if [[ -x "$LOCAL_INSTALLER" ]]; then
+ exec "$LOCAL_INSTALLER" "${FORWARDED_ARGS[@]}"
+fi
+
+run_remote_bootstrap "${FORWARDED_ARGS[@]}"
diff --git a/package.nix b/package.nix
index 89b7c84e2..8bce2d366 100644
--- a/package.nix
+++ b/package.nix
@@ -13,7 +13,7 @@ let
in
rustPlatform.buildRustPackage (finalAttrs: {
pname = "zeroclaw";
- version = "0.1.7";
+ version = "0.1.8";
src =
let
diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh
index cee7251ad..e5a47cea5 100755
--- a/scripts/bootstrap.sh
+++ b/scripts/bootstrap.sh
@@ -22,7 +22,8 @@ Usage:
./bootstrap.sh [options] # compatibility entrypoint
Modes:
- Default mode installs/builds ZeroClaw only (requires existing Rust toolchain).
+ Default mode installs/builds ZeroClaw (requires existing Rust toolchain).
+ No-flag interactive sessions run full-screen TUI onboarding after install.
Guided mode asks setup questions and configures options interactively.
Optional bootstrap mode can also install system dependencies and Rust.
@@ -41,7 +42,7 @@ Options:
--force-source-build Disable prebuilt flow and always build from source
--cargo-features Extra Cargo features for local source build/install (comma-separated)
--onboard Run onboarding after install
- --interactive-onboard Run interactive onboarding (implies --onboard)
+ --interactive-onboard Run full-screen TUI onboarding (implies --onboard; default in no-flag interactive sessions)
--api-key API key for non-interactive onboarding
--provider Provider for non-interactive onboarding (default: openrouter)
--model Model for non-interactive onboarding (optional)
@@ -423,11 +424,18 @@ string_to_bool() {
}
guided_input_stream() {
- if [[ -t 0 ]]; then
+ # Some constrained Linux containers report interactive stdin but deny opening
+ # /dev/stdin directly. Probe readability before selecting it.
+ if [[ -t 0 ]] && (: /dev/null; then
echo "/dev/stdin"
return 0
fi
+ if [[ -t 0 ]] && (: /dev/null; then
+ echo "/proc/self/fd/0"
+ return 0
+ fi
+
if (: /dev/null; then
echo "/dev/tty"
return 0
@@ -627,9 +635,12 @@ run_guided_installer() {
SKIP_INSTALL=true
fi
- if prompt_yes_no "Run onboarding after install?" "no"; then
+ if [[ "$INTERACTIVE_ONBOARD" == true ]]; then
RUN_ONBOARD=true
- if prompt_yes_no "Use interactive onboarding?" "yes"; then
+ info "Onboarding mode preselected: full-screen TUI."
+ elif prompt_yes_no "Run onboarding after install?" "yes"; then
+ RUN_ONBOARD=true
+ if prompt_yes_no "Use full-screen TUI onboarding?" "yes"; then
INTERACTIVE_ONBOARD=true
else
INTERACTIVE_ONBOARD=false
@@ -650,7 +661,7 @@ run_guided_installer() {
fi
if [[ -z "$API_KEY" ]]; then
- if ! guided_read api_key_input "API key (hidden, leave empty to switch to interactive onboarding): " true; then
+ if ! guided_read api_key_input "API key (hidden, leave empty to switch to TUI onboarding): " true; then
echo
error "guided installer input was interrupted."
exit 1
@@ -659,11 +670,14 @@ run_guided_installer() {
if [[ -n "$api_key_input" ]]; then
API_KEY="$api_key_input"
else
- warn "No API key entered. Using interactive onboarding instead."
+ warn "No API key entered. Using TUI onboarding instead."
INTERACTIVE_ONBOARD=true
fi
fi
fi
+ else
+ RUN_ONBOARD=false
+ INTERACTIVE_ONBOARD=false
fi
echo
@@ -1229,8 +1243,8 @@ run_docker_bootstrap() {
if [[ "$RUN_ONBOARD" == true ]]; then
local onboard_cmd=()
if [[ "$INTERACTIVE_ONBOARD" == true ]]; then
- info "Launching interactive onboarding in container"
- onboard_cmd=(onboard --interactive)
+ info "Launching TUI onboarding in container"
+ onboard_cmd=(onboard --interactive-ui)
else
if [[ -z "$API_KEY" ]]; then
cat <<'MSG'
@@ -1239,7 +1253,7 @@ Use either:
--api-key "sk-..."
or:
ZEROCLAW_API_KEY="sk-..." ./zeroclaw_install.sh --docker
-or run interactive:
+or run TUI onboarding:
./zeroclaw_install.sh --docker --interactive-onboard
MSG
exit 1
@@ -1449,6 +1463,11 @@ if [[ "$GUIDED_MODE" == "auto" ]]; then
fi
fi
+if [[ "$ORIGINAL_ARG_COUNT" -eq 0 && -t 1 ]] && (: /dev/null; then
+ RUN_ONBOARD=true
+ INTERACTIVE_ONBOARD=true
+fi
+
if [[ "$DOCKER_MODE" == true && "$GUIDED_MODE" == "on" ]]; then
warn "--guided is ignored with --docker."
GUIDED_MODE="off"
@@ -1699,8 +1718,18 @@ if [[ "$RUN_ONBOARD" == true ]]; then
fi
if [[ "$INTERACTIVE_ONBOARD" == true ]]; then
- info "Running interactive onboarding"
- "$ZEROCLAW_BIN" onboard --interactive
+ info "Running TUI onboarding"
+ if [[ -t 0 && -t 1 ]]; then
+ "$ZEROCLAW_BIN" onboard --interactive-ui
+ elif (: /dev/null; then
+ # `curl ... | bash` leaves stdin as a pipe; hand off terminal control to
+ # the onboarding TUI using the controlling tty.
+ "$ZEROCLAW_BIN" onboard --interactive-ui /dev/tty 2>/dev/tty
+ else
+ error "TUI onboarding requires an interactive terminal."
+ error "Re-run from a terminal: zeroclaw onboard --interactive-ui"
+ exit 1
+ fi
else
if [[ -z "$API_KEY" ]]; then
cat <<'MSG'
@@ -1709,7 +1738,7 @@ Use either:
--api-key "sk-..."
or:
ZEROCLAW_API_KEY="sk-..." ./zeroclaw_install.sh --onboard
-or run interactive:
+or run TUI onboarding:
./zeroclaw_install.sh --interactive-onboard
MSG
exit 1
diff --git a/scripts/ci/agent_team_orchestration_eval.py b/scripts/ci/agent_team_orchestration_eval.py
new file mode 100755
index 000000000..e6e19b4ac
--- /dev/null
+++ b/scripts/ci/agent_team_orchestration_eval.py
@@ -0,0 +1,660 @@
+#!/usr/bin/env python3
+"""Estimate coordination efficiency across agent-team topologies.
+
+This script remains intentionally lightweight so it can run in local and CI
+contexts without external dependencies. It supports:
+
+- topology comparison (`single`, `lead_subagent`, `star_team`, `mesh_team`)
+- budget-aware simulation (`low`, `medium`, `high`)
+- workload and protocol profiles
+- optional degradation policies under budget pressure
+- gate enforcement and recommendation output
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import dataclass
+from typing import Iterable
+
+
+TOPOLOGIES = ("single", "lead_subagent", "star_team", "mesh_team")
+RECOMMENDATION_MODES = ("balanced", "cost", "quality")
+DEGRADATION_POLICIES = ("none", "auto", "aggressive")
+
+
+@dataclass(frozen=True)
+class BudgetProfile:
+ name: str
+ summary_cap_tokens: int
+ max_workers: int
+ compaction_interval_rounds: int
+ message_budget_per_task: int
+ quality_modifier: float
+
+
+@dataclass(frozen=True)
+class WorkloadProfile:
+ name: str
+ execution_multiplier: float
+ sync_multiplier: float
+ summary_multiplier: float
+ latency_multiplier: float
+ quality_modifier: float
+
+
+@dataclass(frozen=True)
+class ProtocolProfile:
+ name: str
+ summary_multiplier: float
+ artifact_discount: float
+ latency_penalty_per_message_s: float
+ cache_bonus: float
+ quality_modifier: float
+
+
+BUDGETS: dict[str, BudgetProfile] = {
+ "low": BudgetProfile(
+ name="low",
+ summary_cap_tokens=80,
+ max_workers=3,
+ compaction_interval_rounds=3,
+ message_budget_per_task=10,
+ quality_modifier=-0.03,
+ ),
+ "medium": BudgetProfile(
+ name="medium",
+ summary_cap_tokens=120,
+ max_workers=5,
+ compaction_interval_rounds=5,
+ message_budget_per_task=20,
+ quality_modifier=0.0,
+ ),
+ "high": BudgetProfile(
+ name="high",
+ summary_cap_tokens=180,
+ max_workers=8,
+ compaction_interval_rounds=8,
+ message_budget_per_task=32,
+ quality_modifier=0.02,
+ ),
+}
+
+
+WORKLOADS: dict[str, WorkloadProfile] = {
+ "implementation": WorkloadProfile(
+ name="implementation",
+ execution_multiplier=1.00,
+ sync_multiplier=1.00,
+ summary_multiplier=1.00,
+ latency_multiplier=1.00,
+ quality_modifier=0.00,
+ ),
+ "debugging": WorkloadProfile(
+ name="debugging",
+ execution_multiplier=1.12,
+ sync_multiplier=1.25,
+ summary_multiplier=1.12,
+ latency_multiplier=1.18,
+ quality_modifier=-0.02,
+ ),
+ "research": WorkloadProfile(
+ name="research",
+ execution_multiplier=0.95,
+ sync_multiplier=0.90,
+ summary_multiplier=0.95,
+ latency_multiplier=0.92,
+ quality_modifier=0.01,
+ ),
+ "mixed": WorkloadProfile(
+ name="mixed",
+ execution_multiplier=1.03,
+ sync_multiplier=1.08,
+ summary_multiplier=1.05,
+ latency_multiplier=1.06,
+ quality_modifier=0.00,
+ ),
+}
+
+
+PROTOCOLS: dict[str, ProtocolProfile] = {
+ "a2a_lite": ProtocolProfile(
+ name="a2a_lite",
+ summary_multiplier=1.00,
+ artifact_discount=0.18,
+ latency_penalty_per_message_s=0.00,
+ cache_bonus=0.02,
+ quality_modifier=0.01,
+ ),
+ "transcript": ProtocolProfile(
+ name="transcript",
+ summary_multiplier=2.20,
+ artifact_discount=0.00,
+ latency_penalty_per_message_s=0.012,
+ cache_bonus=-0.01,
+ quality_modifier=-0.02,
+ ),
+}
+
+
+def _participants(topology: str, budget: BudgetProfile) -> int:
+ if topology == "single":
+ return 1
+ if topology == "lead_subagent":
+ return 2
+ if topology in ("star_team", "mesh_team"):
+ return min(5, budget.max_workers)
+ raise ValueError(f"unknown topology: {topology}")
+
+
+def _execution_factor(topology: str) -> float:
+ factors = {
+ "single": 1.00,
+ "lead_subagent": 0.95,
+ "star_team": 0.92,
+ "mesh_team": 0.97,
+ }
+ return factors[topology]
+
+
+def _base_pass_rate(topology: str) -> float:
+ rates = {
+ "single": 0.78,
+ "lead_subagent": 0.84,
+ "star_team": 0.88,
+ "mesh_team": 0.82,
+ }
+ return rates[topology]
+
+
+def _cache_factor(topology: str) -> float:
+ factors = {
+ "single": 0.05,
+ "lead_subagent": 0.08,
+ "star_team": 0.10,
+ "mesh_team": 0.10,
+ }
+ return factors[topology]
+
+
+def _coordination_messages(
+ *,
+ topology: str,
+ rounds: int,
+ participants: int,
+ workload: WorkloadProfile,
+) -> int:
+ if topology == "single":
+ return 0
+
+ workers = max(1, participants - 1)
+ lead_messages = 2 * workers * rounds
+
+ if topology == "lead_subagent":
+ base_messages = lead_messages
+ elif topology == "star_team":
+ broadcast = workers * rounds
+ base_messages = lead_messages + broadcast
+ elif topology == "mesh_team":
+ peer_messages = workers * max(0, workers - 1) * rounds
+ base_messages = lead_messages + peer_messages
+ else:
+ raise ValueError(f"unknown topology: {topology}")
+
+ return int(round(base_messages * workload.sync_multiplier))
+
+
+def _compute_result(
+ *,
+ topology: str,
+ tasks: int,
+ avg_task_tokens: int,
+ rounds: int,
+ budget: BudgetProfile,
+ workload: WorkloadProfile,
+ protocol: ProtocolProfile,
+ participants_override: int | None = None,
+ summary_scale: float = 1.0,
+ extra_quality_modifier: float = 0.0,
+ model_tier: str = "primary",
+ degradation_applied: bool = False,
+ degradation_actions: list[str] | None = None,
+) -> dict[str, object]:
+ participants = participants_override or _participants(topology, budget)
+ participants = max(1, participants)
+ parallelism = 1 if topology == "single" else max(1, participants - 1)
+
+ execution_tokens = int(
+ tasks
+ * avg_task_tokens
+ * _execution_factor(topology)
+ * workload.execution_multiplier
+ )
+
+ summary_tokens = min(
+ budget.summary_cap_tokens,
+ max(24, int(avg_task_tokens * 0.08)),
+ )
+ summary_tokens = int(summary_tokens * workload.summary_multiplier * protocol.summary_multiplier)
+ summary_tokens = max(16, int(summary_tokens * summary_scale))
+
+ messages = _coordination_messages(
+ topology=topology,
+ rounds=rounds,
+ participants=participants,
+ workload=workload,
+ )
+ raw_coordination_tokens = messages * summary_tokens
+
+ compaction_events = rounds // budget.compaction_interval_rounds
+ compaction_discount = min(0.35, compaction_events * 0.10)
+ coordination_tokens = int(raw_coordination_tokens * (1.0 - compaction_discount))
+ coordination_tokens = int(coordination_tokens * (1.0 - protocol.artifact_discount))
+
+ cache_factor = _cache_factor(topology) + protocol.cache_bonus
+ cache_factor = min(0.30, max(0.0, cache_factor))
+ cache_savings_tokens = int(execution_tokens * cache_factor)
+
+ total_tokens = max(1, execution_tokens + coordination_tokens - cache_savings_tokens)
+ coordination_ratio = coordination_tokens / total_tokens
+
+ pass_rate = (
+ _base_pass_rate(topology)
+ + budget.quality_modifier
+ + workload.quality_modifier
+ + protocol.quality_modifier
+ + extra_quality_modifier
+ )
+ pass_rate = min(0.99, max(0.0, pass_rate))
+ defect_escape = round(max(0.0, 1.0 - pass_rate), 4)
+
+ base_latency_s = (tasks / parallelism) * 6.0 * workload.latency_multiplier
+ sync_penalty_s = messages * (0.02 + protocol.latency_penalty_per_message_s)
+ p95_latency_s = round(base_latency_s + sync_penalty_s, 2)
+
+ throughput_tpd = round((tasks / max(1.0, p95_latency_s)) * 86400.0, 2)
+
+ budget_limit_tokens = tasks * avg_task_tokens + tasks * budget.message_budget_per_task
+ budget_ok = total_tokens <= budget_limit_tokens
+
+ return {
+ "topology": topology,
+ "participants": participants,
+ "model_tier": model_tier,
+ "tasks": tasks,
+ "tasks_per_worker": round(tasks / parallelism, 2),
+ "workload_profile": workload.name,
+ "protocol_mode": protocol.name,
+ "degradation_applied": degradation_applied,
+ "degradation_actions": degradation_actions or [],
+ "execution_tokens": execution_tokens,
+ "coordination_tokens": coordination_tokens,
+ "cache_savings_tokens": cache_savings_tokens,
+ "total_tokens": total_tokens,
+ "coordination_ratio": round(coordination_ratio, 4),
+ "estimated_pass_rate": round(pass_rate, 4),
+ "estimated_defect_escape": defect_escape,
+ "estimated_p95_latency_s": p95_latency_s,
+ "estimated_throughput_tpd": throughput_tpd,
+ "budget_limit_tokens": budget_limit_tokens,
+ "budget_headroom_tokens": budget_limit_tokens - total_tokens,
+ "budget_ok": budget_ok,
+ }
+
+
+def evaluate_topology(
+ *,
+ topology: str,
+ tasks: int,
+ avg_task_tokens: int,
+ rounds: int,
+ budget: BudgetProfile,
+ workload: WorkloadProfile,
+ protocol: ProtocolProfile,
+ degradation_policy: str,
+ coordination_ratio_hint: float,
+) -> dict[str, object]:
+ base = _compute_result(
+ topology=topology,
+ tasks=tasks,
+ avg_task_tokens=avg_task_tokens,
+ rounds=rounds,
+ budget=budget,
+ workload=workload,
+ protocol=protocol,
+ )
+
+ if degradation_policy == "none" or topology == "single":
+ return base
+
+ pressure = (not bool(base["budget_ok"])) or (
+ float(base["coordination_ratio"]) > coordination_ratio_hint
+ )
+ if not pressure:
+ return base
+
+ if degradation_policy == "auto":
+ participant_delta = 1
+ summary_scale = 0.82
+ quality_penalty = -0.01
+ model_tier = "economy"
+ elif degradation_policy == "aggressive":
+ participant_delta = 2
+ summary_scale = 0.65
+ quality_penalty = -0.03
+ model_tier = "economy"
+ else:
+ raise ValueError(f"unknown degradation policy: {degradation_policy}")
+
+ reduced = max(2, int(base["participants"]) - participant_delta)
+ actions = [
+ f"reduce_participants:{base['participants']}->{reduced}",
+ f"tighten_summary_scale:{summary_scale}",
+ f"switch_model_tier:{model_tier}",
+ ]
+
+ return _compute_result(
+ topology=topology,
+ tasks=tasks,
+ avg_task_tokens=avg_task_tokens,
+ rounds=rounds,
+ budget=budget,
+ workload=workload,
+ protocol=protocol,
+ participants_override=reduced,
+ summary_scale=summary_scale,
+ extra_quality_modifier=quality_penalty,
+ model_tier=model_tier,
+ degradation_applied=True,
+ degradation_actions=actions,
+ )
+
+
+def parse_topologies(raw: str) -> list[str]:
+ items = [x.strip() for x in raw.split(",") if x.strip()]
+ invalid = sorted(set(items) - set(TOPOLOGIES))
+ if invalid:
+ raise ValueError(f"invalid topologies: {', '.join(invalid)}")
+ if not items:
+ raise ValueError("topology list is empty")
+ return items
+
+
+def _emit_json(path: str, payload: dict[str, object]) -> None:
+ content = json.dumps(payload, indent=2, sort_keys=False)
+ if path == "-":
+ print(content)
+ return
+
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(content)
+ f.write("\n")
+
+
+def _rank(results: Iterable[dict[str, object]], key: str) -> list[str]:
+ return [x["topology"] for x in sorted(results, key=lambda row: row[key])] # type: ignore[index]
+
+
+def _score_recommendation(
+ *,
+ results: list[dict[str, object]],
+ mode: str,
+) -> dict[str, object]:
+ if not results:
+ return {
+ "mode": mode,
+ "recommended_topology": None,
+ "reason": "no_results",
+ "scores": [],
+ }
+
+ max_tokens = max(int(row["total_tokens"]) for row in results)
+ max_latency = max(float(row["estimated_p95_latency_s"]) for row in results)
+
+ if mode == "balanced":
+ w_quality, w_cost, w_latency = 0.45, 0.35, 0.20
+ elif mode == "cost":
+ w_quality, w_cost, w_latency = 0.25, 0.55, 0.20
+ elif mode == "quality":
+ w_quality, w_cost, w_latency = 0.65, 0.20, 0.15
+ else:
+ raise ValueError(f"unknown recommendation mode: {mode}")
+
+ scored: list[dict[str, object]] = []
+ for row in results:
+ quality = float(row["estimated_pass_rate"])
+ cost_norm = 1.0 - (int(row["total_tokens"]) / max(1, max_tokens))
+ latency_norm = 1.0 - (float(row["estimated_p95_latency_s"]) / max(1.0, max_latency))
+ score = (quality * w_quality) + (cost_norm * w_cost) + (latency_norm * w_latency)
+ scored.append(
+ {
+ "topology": row["topology"],
+ "score": round(score, 5),
+ "gate_pass": row["gate_pass"],
+ }
+ )
+
+ scored.sort(key=lambda x: float(x["score"]), reverse=True)
+ return {
+ "mode": mode,
+ "recommended_topology": scored[0]["topology"],
+ "reason": "weighted_score",
+ "scores": scored,
+ }
+
+
+def _apply_gates(
+ *,
+ row: dict[str, object],
+ max_coordination_ratio: float,
+ min_pass_rate: float,
+ max_p95_latency: float,
+) -> dict[str, object]:
+ coord_ok = float(row["coordination_ratio"]) <= max_coordination_ratio
+ quality_ok = float(row["estimated_pass_rate"]) >= min_pass_rate
+ latency_ok = float(row["estimated_p95_latency_s"]) <= max_p95_latency
+ budget_ok = bool(row["budget_ok"])
+
+ row["gates"] = {
+ "coordination_ratio_ok": coord_ok,
+ "quality_ok": quality_ok,
+ "latency_ok": latency_ok,
+ "budget_ok": budget_ok,
+ }
+ row["gate_pass"] = coord_ok and quality_ok and latency_ok and budget_ok
+ return row
+
+
+def _evaluate_budget(
+ *,
+ budget: BudgetProfile,
+ args: argparse.Namespace,
+ topologies: list[str],
+ workload: WorkloadProfile,
+ protocol: ProtocolProfile,
+) -> dict[str, object]:
+ rows = [
+ evaluate_topology(
+ topology=t,
+ tasks=args.tasks,
+ avg_task_tokens=args.avg_task_tokens,
+ rounds=args.coordination_rounds,
+ budget=budget,
+ workload=workload,
+ protocol=protocol,
+ degradation_policy=args.degradation_policy,
+ coordination_ratio_hint=args.max_coordination_ratio,
+ )
+ for t in topologies
+ ]
+
+ rows = [
+ _apply_gates(
+ row=r,
+ max_coordination_ratio=args.max_coordination_ratio,
+ min_pass_rate=args.min_pass_rate,
+ max_p95_latency=args.max_p95_latency,
+ )
+ for r in rows
+ ]
+
+ gate_pass_rows = [r for r in rows if bool(r["gate_pass"])]
+
+ recommendation_pool = gate_pass_rows if gate_pass_rows else rows
+ recommendation = _score_recommendation(
+ results=recommendation_pool,
+ mode=args.recommendation_mode,
+ )
+ recommendation["used_gate_filtered_pool"] = bool(gate_pass_rows)
+
+ return {
+ "budget_profile": budget.name,
+ "results": rows,
+ "rankings": {
+ "cost_asc": _rank(rows, "total_tokens"),
+ "coordination_ratio_asc": _rank(rows, "coordination_ratio"),
+ "latency_asc": _rank(rows, "estimated_p95_latency_s"),
+ "pass_rate_desc": [
+ x["topology"]
+ for x in sorted(rows, key=lambda row: row["estimated_pass_rate"], reverse=True)
+ ],
+ },
+ "recommendation": recommendation,
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--budget", choices=sorted(BUDGETS.keys()), default="medium")
+ parser.add_argument("--all-budgets", action="store_true")
+ parser.add_argument("--tasks", type=int, default=24)
+ parser.add_argument("--avg-task-tokens", type=int, default=1400)
+ parser.add_argument("--coordination-rounds", type=int, default=4)
+ parser.add_argument(
+ "--topologies",
+ default=",".join(TOPOLOGIES),
+ help=f"comma-separated list: {','.join(TOPOLOGIES)}",
+ )
+ parser.add_argument("--workload-profile", choices=sorted(WORKLOADS.keys()), default="mixed")
+ parser.add_argument("--protocol-mode", choices=sorted(PROTOCOLS.keys()), default="a2a_lite")
+ parser.add_argument(
+ "--degradation-policy",
+ choices=DEGRADATION_POLICIES,
+ default="none",
+ )
+ parser.add_argument(
+ "--recommendation-mode",
+ choices=RECOMMENDATION_MODES,
+ default="balanced",
+ )
+ parser.add_argument("--max-coordination-ratio", type=float, default=0.20)
+ parser.add_argument("--min-pass-rate", type=float, default=0.80)
+ parser.add_argument("--max-p95-latency", type=float, default=180.0)
+ parser.add_argument("--json-output", default="-")
+ parser.add_argument("--enforce-gates", action="store_true")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ if args.tasks <= 0:
+ parser.error("--tasks must be > 0")
+ if args.avg_task_tokens <= 0:
+ parser.error("--avg-task-tokens must be > 0")
+ if args.coordination_rounds < 0:
+ parser.error("--coordination-rounds must be >= 0")
+ if not (0.0 < args.max_coordination_ratio < 1.0):
+ parser.error("--max-coordination-ratio must be in (0, 1)")
+ if not (0.0 < args.min_pass_rate <= 1.0):
+ parser.error("--min-pass-rate must be in (0, 1]")
+ if args.max_p95_latency <= 0.0:
+ parser.error("--max-p95-latency must be > 0")
+
+ try:
+ topologies = parse_topologies(args.topologies)
+ except ValueError as exc:
+ parser.error(str(exc))
+
+ workload = WORKLOADS[args.workload_profile]
+ protocol = PROTOCOLS[args.protocol_mode]
+
+ budget_targets = list(BUDGETS.values()) if args.all_budgets else [BUDGETS[args.budget]]
+
+ budget_reports = [
+ _evaluate_budget(
+ budget=budget,
+ args=args,
+ topologies=topologies,
+ workload=workload,
+ protocol=protocol,
+ )
+ for budget in budget_targets
+ ]
+
+ primary = budget_reports[0]
+ payload: dict[str, object] = {
+ "schema_version": "zeroclaw.agent-team-eval.v1",
+ "budget_profile": primary["budget_profile"],
+ "inputs": {
+ "tasks": args.tasks,
+ "avg_task_tokens": args.avg_task_tokens,
+ "coordination_rounds": args.coordination_rounds,
+ "topologies": topologies,
+ "workload_profile": args.workload_profile,
+ "protocol_mode": args.protocol_mode,
+ "degradation_policy": args.degradation_policy,
+ "recommendation_mode": args.recommendation_mode,
+ "max_coordination_ratio": args.max_coordination_ratio,
+ "min_pass_rate": args.min_pass_rate,
+ "max_p95_latency": args.max_p95_latency,
+ },
+ "results": primary["results"],
+ "rankings": primary["rankings"],
+ "recommendation": primary["recommendation"],
+ }
+
+ if args.all_budgets:
+ payload["budget_sweep"] = budget_reports
+
+ _emit_json(args.json_output, payload)
+
+ if not args.enforce_gates:
+ return 0
+
+ violations: list[str] = []
+ for report in budget_reports:
+ budget_name = report["budget_profile"]
+ for row in report["results"]: # type: ignore[index]
+ if bool(row["gate_pass"]):
+ continue
+ gates = row["gates"]
+ if not gates["coordination_ratio_ok"]:
+ violations.append(
+ f"{budget_name}:{row['topology']}: coordination_ratio={row['coordination_ratio']}"
+ )
+ if not gates["quality_ok"]:
+ violations.append(
+ f"{budget_name}:{row['topology']}: pass_rate={row['estimated_pass_rate']}"
+ )
+ if not gates["latency_ok"]:
+ violations.append(
+ f"{budget_name}:{row['topology']}: p95_latency_s={row['estimated_p95_latency_s']}"
+ )
+ if not gates["budget_ok"]:
+ violations.append(f"{budget_name}:{row['topology']}: exceeded budget_limit_tokens")
+
+ if violations:
+ print("gate violations detected:", file=sys.stderr)
+ for item in violations:
+ print(f"- {item}", file=sys.stderr)
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/ci/check_binary_size.sh b/scripts/ci/check_binary_size.sh
index 6b9527bae..ce0b6eca4 100755
--- a/scripts/ci/check_binary_size.sh
+++ b/scripts/ci/check_binary_size.sh
@@ -8,9 +8,23 @@
# label Optional label for step summary (e.g. target triple)
#
# Thresholds:
-# >20MB — hard error (safeguard)
-# >15MB — warning (advisory)
-# >5MB — warning (target)
+# macOS / default host:
+# >22MB — hard error (safeguard)
+# >15MB — warning (advisory)
+# Linux host:
+# >26MB — hard error (safeguard)
+# >20MB — warning (advisory)
+# All hosts:
+# >5MB — warning (target)
+#
+# Overrides:
+# BINARY_SIZE_HARD_LIMIT_BYTES
+# BINARY_SIZE_ADVISORY_LIMIT_BYTES
+# BINARY_SIZE_TARGET_LIMIT_BYTES
+# Legacy compatibility:
+# BINARY_SIZE_HARD_LIMIT_MB
+# BINARY_SIZE_ADVISORY_MB
+# BINARY_SIZE_TARGET_MB
#
# Writes to GITHUB_STEP_SUMMARY when the variable is set and label is provided.
@@ -19,6 +33,20 @@ set -euo pipefail
BIN="${1:?Usage: check_binary_size.sh [label]}"
LABEL="${2:-}"
+if [ ! -f "$BIN" ] && [ -n "${CARGO_TARGET_DIR:-}" ]; then
+ if [[ "$BIN" == target/* ]]; then
+ alt_bin="${CARGO_TARGET_DIR}/${BIN#target/}"
+ if [ -f "$alt_bin" ]; then
+ BIN="$alt_bin"
+ fi
+ elif [[ "$BIN" != /* ]]; then
+ alt_bin="${CARGO_TARGET_DIR}/${BIN}"
+ if [ -f "$alt_bin" ]; then
+ BIN="$alt_bin"
+ fi
+ fi
+fi
+
if [ ! -f "$BIN" ]; then
echo "::error::Binary not found at $BIN"
exit 1
@@ -29,18 +57,58 @@ SIZE=$(stat -f%z "$BIN" 2>/dev/null || stat -c%s "$BIN")
SIZE_MB=$((SIZE / 1024 / 1024))
echo "Binary size: ${SIZE_MB}MB ($SIZE bytes)"
+# Default thresholds.
+HARD_LIMIT_BYTES=23068672 # 22MB
+ADVISORY_LIMIT_BYTES=15728640 # 15MB
+TARGET_LIMIT_BYTES=5242880 # 5MB
+
+# Linux host builds are typically larger than macOS builds.
+HOST_OS="$(uname -s 2>/dev/null || echo "")"
+HOST_OS_LC="$(printf '%s' "$HOST_OS" | tr '[:upper:]' '[:lower:]')"
+if [ "$HOST_OS_LC" = "linux" ]; then
+ HARD_LIMIT_BYTES=27262976 # 26MB
+ ADVISORY_LIMIT_BYTES=20971520 # 20MB
+fi
+
+# Explicit env overrides always win.
+if [ -n "${BINARY_SIZE_HARD_LIMIT_BYTES:-}" ]; then
+ HARD_LIMIT_BYTES="$BINARY_SIZE_HARD_LIMIT_BYTES"
+fi
+if [ -n "${BINARY_SIZE_ADVISORY_LIMIT_BYTES:-}" ]; then
+ ADVISORY_LIMIT_BYTES="$BINARY_SIZE_ADVISORY_LIMIT_BYTES"
+fi
+if [ -n "${BINARY_SIZE_TARGET_LIMIT_BYTES:-}" ]; then
+ TARGET_LIMIT_BYTES="$BINARY_SIZE_TARGET_LIMIT_BYTES"
+fi
+
+# Backward-compatible MB overrides (used in older workflow configs).
+if [ -z "${BINARY_SIZE_HARD_LIMIT_BYTES:-}" ] && [ -n "${BINARY_SIZE_HARD_LIMIT_MB:-}" ]; then
+ HARD_LIMIT_BYTES=$((BINARY_SIZE_HARD_LIMIT_MB * 1024 * 1024))
+fi
+if [ -z "${BINARY_SIZE_ADVISORY_LIMIT_BYTES:-}" ] && [ -n "${BINARY_SIZE_ADVISORY_MB:-}" ]; then
+ ADVISORY_LIMIT_BYTES=$((BINARY_SIZE_ADVISORY_MB * 1024 * 1024))
+fi
+if [ -z "${BINARY_SIZE_TARGET_LIMIT_BYTES:-}" ] && [ -n "${BINARY_SIZE_TARGET_MB:-}" ]; then
+ TARGET_LIMIT_BYTES=$((BINARY_SIZE_TARGET_MB * 1024 * 1024))
+fi
+
+HARD_LIMIT_MB=$((HARD_LIMIT_BYTES / 1024 / 1024))
+ADVISORY_LIMIT_MB=$((ADVISORY_LIMIT_BYTES / 1024 / 1024))
+TARGET_LIMIT_MB=$((TARGET_LIMIT_BYTES / 1024 / 1024))
+
if [ -n "$LABEL" ] && [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "### Binary Size: $LABEL" >> "$GITHUB_STEP_SUMMARY"
echo "- Size: ${SIZE_MB}MB ($SIZE bytes)" >> "$GITHUB_STEP_SUMMARY"
+ echo "- Limits: hard=${HARD_LIMIT_MB}MB advisory=${ADVISORY_LIMIT_MB}MB target=${TARGET_LIMIT_MB}MB" >> "$GITHUB_STEP_SUMMARY"
fi
-if [ "$SIZE" -gt 20971520 ]; then
- echo "::error::Binary exceeds 20MB safeguard (${SIZE_MB}MB)"
+if [ "$SIZE" -gt "$HARD_LIMIT_BYTES" ]; then
+ echo "::error::Binary exceeds ${HARD_LIMIT_MB}MB safeguard (${SIZE_MB}MB)"
exit 1
-elif [ "$SIZE" -gt 15728640 ]; then
- echo "::warning::Binary exceeds 15MB advisory target (${SIZE_MB}MB)"
-elif [ "$SIZE" -gt 5242880 ]; then
- echo "::warning::Binary exceeds 5MB target (${SIZE_MB}MB)"
+elif [ "$SIZE" -gt "$ADVISORY_LIMIT_BYTES" ]; then
+ echo "::warning::Binary exceeds ${ADVISORY_LIMIT_MB}MB advisory target (${SIZE_MB}MB)"
+elif [ "$SIZE" -gt "$TARGET_LIMIT_BYTES" ]; then
+ echo "::warning::Binary exceeds ${TARGET_LIMIT_MB}MB target (${SIZE_MB}MB)"
else
echo "Binary size within target."
fi
diff --git a/scripts/ci/ensure_c_toolchain.sh b/scripts/ci/ensure_c_toolchain.sh
new file mode 100755
index 000000000..92caee447
--- /dev/null
+++ b/scripts/ci/ensure_c_toolchain.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+set_env_var() {
+ local key="$1"
+ local value="$2"
+ if [ -n "${GITHUB_ENV:-}" ]; then
+ echo "${key}=${value}" >>"${GITHUB_ENV}"
+ fi
+}
+
+configure_linker() {
+ local linker="$1"
+ if [ ! -x "${linker}" ]; then
+ return 1
+ fi
+
+ set_env_var "CC" "${linker}"
+ set_env_var "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER" "${linker}"
+
+ if command -v g++ >/dev/null 2>&1; then
+ set_env_var "CXX" "$(command -v g++)"
+ elif command -v clang++ >/dev/null 2>&1; then
+ set_env_var "CXX" "$(command -v clang++)"
+ fi
+
+ echo "Using C linker: ${linker}"
+ "${linker}" --version | head -n 1 || true
+ return 0
+}
+
+echo "Ensuring C toolchain is available for Rust native dependencies"
+
+if command -v cc >/dev/null 2>&1; then
+ configure_linker "$(command -v cc)"
+ exit 0
+fi
+
+if command -v gcc >/dev/null 2>&1; then
+ configure_linker "$(command -v gcc)"
+ exit 0
+fi
+
+if command -v clang >/dev/null 2>&1; then
+ configure_linker "$(command -v clang)"
+ exit 0
+fi
+
+resolve_cc_after_bootstrap() {
+ if command -v cc >/dev/null 2>&1; then
+ command -v cc
+ return 0
+ fi
+
+ local shim_dir="${RUNNER_TEMP:-/tmp}/cc-shim"
+ local shim_cc="${shim_dir}/cc"
+ if [ -x "${shim_cc}" ]; then
+ export PATH="${shim_dir}:${PATH}"
+ command -v cc
+ return 0
+ fi
+
+ return 1
+}
+
+# Prefer the resilient provisioning path (package manager + Zig fallback) used by CI Rust jobs.
+if [ -x "${script_dir}/ensure_cc.sh" ]; then
+ if bash "${script_dir}/ensure_cc.sh"; then
+ if cc_path="$(resolve_cc_after_bootstrap)"; then
+ configure_linker "${cc_path}"
+ exit 0
+ fi
+ echo "::warning::C toolchain bootstrap reported success but 'cc' is still unavailable in current step."
+ fi
+fi
+
+if [ "${ALLOW_MISSING_C_TOOLCHAIN:-}" = "1" ] || [ "${ALLOW_MISSING_C_TOOLCHAIN:-}" = "true" ]; then
+ echo "::warning::No usable C compiler found; continuing because ALLOW_MISSING_C_TOOLCHAIN is enabled."
+ exit 0
+fi
+
+echo "No usable C compiler found (cc/gcc/clang)." >&2
+exit 1
diff --git a/scripts/ci/ensure_cargo_component.sh b/scripts/ci/ensure_cargo_component.sh
new file mode 100755
index 000000000..4c56d06d9
--- /dev/null
+++ b/scripts/ci/ensure_cargo_component.sh
@@ -0,0 +1,199 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+requested_toolchain="${1:-1.92.0}"
+fallback_toolchain="${2:-stable}"
+strict_mode_raw="${3:-${ENSURE_CARGO_COMPONENT_STRICT:-false}}"
+strict_mode="$(printf '%s' "${strict_mode_raw}" | tr '[:upper:]' '[:lower:]')"
+required_components_raw="${4:-${ENSURE_RUST_COMPONENTS:-auto}}"
+job_name="$(printf '%s' "${GITHUB_JOB:-}" | tr '[:upper:]' '[:lower:]')"
+
+is_truthy() {
+ local value="${1:-}"
+ case "${value}" in
+ 1 | true | yes | on) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+probe_cargo() {
+ local toolchain="$1"
+ rustup run "${toolchain}" cargo --version >/dev/null 2>&1
+}
+
+probe_rustc() {
+ local toolchain="$1"
+ rustup run "${toolchain}" rustc --version >/dev/null 2>&1
+}
+
+probe_rustfmt() {
+ local toolchain="$1"
+ rustup run "${toolchain}" cargo fmt --version >/dev/null 2>&1
+}
+
+component_available() {
+ local toolchain="$1"
+ local component="$2"
+ rustup component list --toolchain "${toolchain}" \
+ | grep -Eq "^${component}(-[[:alnum:]_:-]+)? "
+}
+
+component_installed() {
+ local toolchain="$1"
+ local component="$2"
+ rustup component list --toolchain "${toolchain}" --installed \
+ | grep -Eq "^${component}(-[[:alnum:]_:-]+)? \\(installed\\)$"
+}
+
+install_component_or_fail() {
+ local toolchain="$1"
+ local component="$2"
+
+ if ! component_available "${toolchain}" "${component}"; then
+ echo "::error::component '${component}' is unavailable for toolchain ${toolchain}."
+ return 1
+ fi
+ if ! rustup component add --toolchain "${toolchain}" "${component}"; then
+ echo "::error::failed to install required component '${component}' for ${toolchain}."
+ return 1
+ fi
+}
+
+probe_rustdoc() {
+ local toolchain="$1"
+ component_installed "${toolchain}" "rust-docs"
+}
+
+ensure_required_tooling() {
+ local toolchain="$1"
+ local required_components="${2:-}"
+
+ if [ -z "${required_components}" ]; then
+ return 0
+ fi
+
+ for component in ${required_components}; do
+ install_component_or_fail "${toolchain}" "${component}" || return 1
+ done
+
+ if [[ " ${required_components} " == *" rustfmt "* ]] && ! probe_rustfmt "${toolchain}"; then
+ echo "::error::rustfmt is unavailable for toolchain ${toolchain}."
+ install_component_or_fail "${toolchain}" "rustfmt" || return 1
+ if ! probe_rustfmt "${toolchain}"; then
+ return 1
+ fi
+ fi
+
+ if [[ " ${required_components} " == *" rust-docs "* ]] && ! probe_rustdoc "${toolchain}"; then
+ echo "::error::rustdoc is unavailable for toolchain ${toolchain}."
+ install_component_or_fail "${toolchain}" "rust-docs" || return 1
+ if ! probe_rustdoc "${toolchain}"; then
+ return 1
+ fi
+ fi
+}
+
+default_required_components() {
+ local normalized_job_name="${1:-}"
+ local components=()
+ [[ "${normalized_job_name}" == *lint* ]] && components+=("rustfmt")
+ [[ "${normalized_job_name}" == *test* ]] && components+=("rust-docs")
+ echo "${components[*]}"
+}
+
+export_toolchain_for_next_steps() {
+ local toolchain="$1"
+ if [ -z "${GITHUB_ENV:-}" ]; then
+ return 0
+ fi
+
+ {
+ echo "RUSTUP_TOOLCHAIN=${toolchain}"
+ cargo_path="$(rustup which --toolchain "${toolchain}" cargo 2>/dev/null || true)"
+ rustc_path="$(rustup which --toolchain "${toolchain}" rustc 2>/dev/null || true)"
+ if [ -n "${cargo_path}" ]; then
+ echo "CARGO=${cargo_path}"
+ fi
+ if [ -n "${rustc_path}" ]; then
+ echo "RUSTC=${rustc_path}"
+ fi
+ } >>"${GITHUB_ENV}"
+}
+
+assert_rustc_version_matches() {
+ local toolchain="$1"
+ local expected_version="$2"
+ local actual_version
+
+ if [[ ! "${expected_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ return 0
+ fi
+
+ actual_version="$(rustup run "${toolchain}" rustc --version | awk '{print $2}')"
+ if [ "${actual_version}" != "${expected_version}" ]; then
+ echo "rustc version mismatch for ${toolchain}: expected ${expected_version}, got ${actual_version}" >&2
+ exit 1
+ fi
+}
+
+selected_toolchain="${requested_toolchain}"
+
+echo "Ensuring cargo component is available for toolchain: ${requested_toolchain}"
+
+if ! probe_rustc "${requested_toolchain}"; then
+ echo "Requested toolchain ${requested_toolchain} is not installed; installing..."
+ rustup toolchain install "${requested_toolchain}" --profile default
+fi
+
+if ! probe_cargo "${requested_toolchain}"; then
+ echo "cargo is unavailable for ${requested_toolchain}; reinstalling toolchain profile..."
+ rustup toolchain install "${requested_toolchain}" --profile default
+ rustup component add cargo --toolchain "${requested_toolchain}" || true
+fi
+
+if ! probe_cargo "${requested_toolchain}"; then
+ if is_truthy "${strict_mode}"; then
+ echo "::error::Strict mode enabled; cargo is unavailable for requested toolchain ${requested_toolchain}." >&2
+ rustup toolchain list || true
+ exit 1
+ fi
+ echo "::warning::Falling back to ${fallback_toolchain} because ${requested_toolchain} cargo remains unavailable."
+ rustup toolchain install "${fallback_toolchain}" --profile default
+ rustup component add cargo --toolchain "${fallback_toolchain}" || true
+ if ! probe_cargo "${fallback_toolchain}"; then
+ echo "No usable cargo found for ${requested_toolchain} or ${fallback_toolchain}" >&2
+ rustup toolchain list || true
+ exit 1
+ fi
+ selected_toolchain="${fallback_toolchain}"
+fi
+
+if is_truthy "${strict_mode}" && [ "${selected_toolchain}" != "${requested_toolchain}" ]; then
+ echo "::error::Strict mode enabled; refusing fallback toolchain ${selected_toolchain} (requested ${requested_toolchain})." >&2
+ exit 1
+fi
+
+required_components="${required_components_raw}"
+if [ "${required_components}" = "auto" ]; then
+ required_components="$(default_required_components "${job_name}")"
+fi
+
+if [ -n "${required_components}" ]; then
+ echo "Ensuring Rust components for job '${job_name:-unknown}': ${required_components}"
+fi
+
+if ! ensure_required_tooling "${selected_toolchain}" "${required_components}"; then
+ echo "Required Rust tooling unavailable for ${selected_toolchain}" >&2
+ rustup toolchain list || true
+ exit 1
+fi
+
+if is_truthy "${strict_mode}"; then
+ assert_rustc_version_matches "${selected_toolchain}" "${requested_toolchain}"
+fi
+
+export_toolchain_for_next_steps "${selected_toolchain}"
+
+echo "Using Rust toolchain: ${selected_toolchain}"
+rustup run "${selected_toolchain}" rustc --version
+rustup run "${selected_toolchain}" cargo --version
diff --git a/scripts/ci/ensure_cc.sh b/scripts/ci/ensure_cc.sh
new file mode 100755
index 000000000..753d3e33c
--- /dev/null
+++ b/scripts/ci/ensure_cc.sh
@@ -0,0 +1,209 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+print_cc_info() {
+ echo "C compiler available: $(command -v cc)"
+ cc --version | head -n1 || true
+}
+
+print_ar_info() {
+ echo "Archiver available: $(command -v ar)"
+ ar --version 2>/dev/null | head -n1 || true
+}
+
+toolchain_ready() {
+ command -v cc >/dev/null 2>&1 && command -v ar >/dev/null 2>&1
+}
+
+prepend_path() {
+ local dir="$1"
+ export PATH="${dir}:${PATH}"
+ if [ -n "${GITHUB_PATH:-}" ]; then
+ echo "${dir}" >> "${GITHUB_PATH}"
+ fi
+}
+
+shim_cc_to_compiler() {
+ local compiler="$1"
+ local compiler_path
+ local shim_dir
+ if ! command -v "${compiler}" >/dev/null 2>&1; then
+ return 1
+ fi
+ compiler_path="$(command -v "${compiler}")"
+ shim_dir="${RUNNER_TEMP:-/tmp}/cc-shim"
+ mkdir -p "${shim_dir}"
+ ln -sf "${compiler_path}" "${shim_dir}/cc"
+ prepend_path "${shim_dir}"
+ echo "::notice::Created 'cc' shim from ${compiler_path}."
+}
+
+shim_ar_to_tool() {
+ local tool="$1"
+ local tool_path
+ local shim_dir
+ if ! command -v "${tool}" >/dev/null 2>&1; then
+ return 1
+ fi
+ tool_path="$(command -v "${tool}")"
+ shim_dir="${RUNNER_TEMP:-/tmp}/cc-shim"
+ mkdir -p "${shim_dir}"
+ ln -sf "${tool_path}" "${shim_dir}/ar"
+ prepend_path "${shim_dir}"
+ echo "::notice::Created 'ar' shim from ${tool_path}."
+}
+
+ensure_archiver() {
+ if command -v ar >/dev/null 2>&1; then
+ return 0
+ fi
+ shim_ar_to_tool llvm-ar && return 0
+ shim_ar_to_tool gcc-ar && return 0
+ return 1
+}
+
+finish_if_ready() {
+ ensure_archiver || true
+ if toolchain_ready; then
+ print_cc_info
+ print_ar_info
+ exit 0
+ fi
+}
+
+run_as_privileged() {
+ if [ "$(id -u)" -eq 0 ]; then
+ "$@"
+ return $?
+ fi
+ if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
+ sudo -n "$@"
+ return $?
+ fi
+ return 1
+}
+
+install_cc_toolchain() {
+ if command -v apt-get >/dev/null 2>&1; then
+ run_as_privileged apt-get update
+ run_as_privileged env DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential binutils pkg-config
+ elif command -v yum >/dev/null 2>&1; then
+ run_as_privileged yum install -y gcc gcc-c++ binutils make pkgconfig
+ elif command -v dnf >/dev/null 2>&1; then
+ run_as_privileged dnf install -y gcc gcc-c++ binutils make pkgconf-pkg-config
+ elif command -v apk >/dev/null 2>&1; then
+ run_as_privileged apk add --no-cache build-base pkgconf
+ else
+ return 1
+ fi
+}
+
+install_zig_cc_shim() {
+ local zig_version="0.14.0"
+ local platform
+ local archive_name
+ local base_dir
+ local extract_dir
+ local archive_path
+ local download_url
+ local shim_dir
+ local zig_bin
+
+ case "$(uname -s)/$(uname -m)" in
+ Linux/x86_64) platform="linux-x86_64" ;;
+ Linux/aarch64 | Linux/arm64) platform="linux-aarch64" ;;
+ Darwin/x86_64) platform="macos-x86_64" ;;
+ Darwin/arm64) platform="macos-aarch64" ;;
+ *)
+ return 1
+ ;;
+ esac
+
+ archive_name="zig-${platform}-${zig_version}"
+ base_dir="${RUNNER_TEMP:-/tmp}/zig"
+ extract_dir="${base_dir}/${archive_name}"
+ archive_path="${base_dir}/${archive_name}.tar.xz"
+ download_url="https://ziglang.org/download/${zig_version}/${archive_name}.tar.xz"
+ zig_bin="${extract_dir}/zig"
+
+ mkdir -p "${base_dir}"
+
+ if [ ! -x "${zig_bin}" ]; then
+ if command -v curl >/dev/null 2>&1; then
+ curl -fsSL "${download_url}" -o "${archive_path}"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "${archive_path}" "${download_url}"
+ else
+ return 1
+ fi
+ tar -xJf "${archive_path}" -C "${base_dir}"
+ fi
+
+ if [ ! -x "${zig_bin}" ]; then
+ return 1
+ fi
+
+ shim_dir="${RUNNER_TEMP:-/tmp}/cc-shim"
+ mkdir -p "${shim_dir}"
+ cat > "${shim_dir}/cc" < "${shim_dir}/ar" </dev/null 2>&1; then
+ finish_if_ready
+fi
+
+if shim_cc_to_compiler clang; then
+ finish_if_ready
+fi
+
+if shim_cc_to_compiler gcc; then
+ finish_if_ready
+fi
+
+echo "::warning::Missing 'cc' on runner. Attempting package-manager install."
+if ! install_cc_toolchain; then
+ echo "::warning::Unable to install compiler via package manager (missing privilege or unsupported manager)."
+fi
+
+if command -v cc >/dev/null 2>&1; then
+ finish_if_ready
+fi
+
+if install_zig_cc_shim; then
+ finish_if_ready
+fi
+
+if shim_cc_to_compiler clang; then
+ finish_if_ready
+fi
+
+if shim_cc_to_compiler gcc; then
+ finish_if_ready
+fi
+
+echo "::error::Failed to provision 'cc' and 'ar'. Install a compiler/binutils toolchain or configure passwordless sudo on the runner."
+exit 1
diff --git a/scripts/ci/install_gitleaks.sh b/scripts/ci/install_gitleaks.sh
index b64e30099..b25ad456c 100755
--- a/scripts/ci/install_gitleaks.sh
+++ b/scripts/ci/install_gitleaks.sh
@@ -6,10 +6,47 @@ set -euo pipefail
BIN_DIR="${1:-${RUNNER_TEMP:-/tmp}/bin}"
VERSION="${2:-${GITLEAKS_VERSION:-v8.24.2}}"
-ARCHIVE="gitleaks_${VERSION#v}_linux_x64.tar.gz"
+
+os_name="$(uname -s | tr '[:upper:]' '[:lower:]')"
+case "$os_name" in
+ linux|darwin) ;;
+ *)
+ echo "Unsupported OS for gitleaks installer: ${os_name}" >&2
+ exit 2
+ ;;
+esac
+
+arch_name="$(uname -m)"
+case "$arch_name" in
+ x86_64|amd64) arch_name="x64" ;;
+ aarch64|arm64) arch_name="arm64" ;;
+ armv7l) arch_name="armv7" ;;
+ armv6l) arch_name="armv6" ;;
+ i386|i686) arch_name="x32" ;;
+ *)
+ echo "Unsupported architecture for gitleaks installer: ${arch_name}" >&2
+ exit 2
+ ;;
+esac
+
+ARCHIVE="gitleaks_${VERSION#v}_${os_name}_${arch_name}.tar.gz"
CHECKSUMS="gitleaks_${VERSION#v}_checksums.txt"
BASE_URL="https://github.com/gitleaks/gitleaks/releases/download/${VERSION}"
+verify_sha256() {
+ local checksum_file="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum -c "$checksum_file"
+ return
+ fi
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 -c "$checksum_file"
+ return
+ fi
+ echo "Neither sha256sum nor shasum is available for checksum verification." >&2
+ exit 127
+}
+
mkdir -p "${BIN_DIR}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
@@ -20,7 +57,7 @@ curl -sSfL "${BASE_URL}/${CHECKSUMS}" -o "${tmp_dir}/${CHECKSUMS}"
grep " ${ARCHIVE}\$" "${tmp_dir}/${CHECKSUMS}" > "${tmp_dir}/gitleaks.sha256"
(
cd "${tmp_dir}"
- sha256sum -c gitleaks.sha256
+ verify_sha256 gitleaks.sha256
)
tar -xzf "${tmp_dir}/${ARCHIVE}" -C "${tmp_dir}" gitleaks
diff --git a/scripts/ci/install_syft.sh b/scripts/ci/install_syft.sh
index 434fc78ec..f19307f0d 100755
--- a/scripts/ci/install_syft.sh
+++ b/scripts/ci/install_syft.sh
@@ -7,6 +7,33 @@ set -euo pipefail
BIN_DIR="${1:-${RUNNER_TEMP:-/tmp}/bin}"
VERSION="${2:-${SYFT_VERSION:-v1.42.1}}"
+download_file() {
+ local url="$1"
+ local output="$2"
+ if command -v curl >/dev/null 2>&1; then
+ curl -sSfL "${url}" -o "${output}"
+ elif command -v wget >/dev/null 2>&1; then
+ wget -qO "${output}" "${url}"
+ else
+ echo "Missing downloader: install curl or wget" >&2
+ return 1
+ fi
+}
+
+verify_sha256() {
+ local checksum_file="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum -c "${checksum_file}"
+ return
+ fi
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 -c "${checksum_file}"
+ return
+ fi
+ echo "Neither sha256sum nor shasum is available for checksum verification." >&2
+ exit 127
+}
+
os_name="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$os_name" in
linux|darwin) ;;
@@ -35,8 +62,8 @@ mkdir -p "${BIN_DIR}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
-curl -sSfL "${BASE_URL}/${ARCHIVE}" -o "${tmp_dir}/${ARCHIVE}"
-curl -sSfL "${BASE_URL}/${CHECKSUMS}" -o "${tmp_dir}/${CHECKSUMS}"
+download_file "${BASE_URL}/${ARCHIVE}" "${tmp_dir}/${ARCHIVE}"
+download_file "${BASE_URL}/${CHECKSUMS}" "${tmp_dir}/${CHECKSUMS}"
awk -v target="${ARCHIVE}" '$2 == target {print $1 " " $2}' "${tmp_dir}/${CHECKSUMS}" > "${tmp_dir}/syft.sha256"
if [ ! -s "${tmp_dir}/syft.sha256" ]; then
@@ -45,7 +72,7 @@ if [ ! -s "${tmp_dir}/syft.sha256" ]; then
fi
(
cd "${tmp_dir}"
- sha256sum -c syft.sha256
+ verify_sha256 syft.sha256
)
tar -xzf "${tmp_dir}/${ARCHIVE}" -C "${tmp_dir}" syft
diff --git a/scripts/ci/queue_hygiene.py b/scripts/ci/queue_hygiene.py
index 9255e9b64..3a9e5af91 100755
--- a/scripts/ci/queue_hygiene.py
+++ b/scripts/ci/queue_hygiene.py
@@ -66,12 +66,30 @@ def parse_args() -> argparse.Namespace:
action="store_true",
help="Also dedupe non-PR runs (push/manual). Default dedupe scope is PR-originated runs only.",
)
+ parser.add_argument(
+ "--non-pr-key",
+ default="sha",
+ choices=["sha", "branch"],
+ help=(
+ "Identity key mode for non-PR dedupe when --dedupe-include-non-pr is enabled: "
+ "'sha' keeps one run per commit (default), 'branch' keeps one run per branch."
+ ),
+ )
parser.add_argument(
"--max-cancel",
type=int,
default=200,
help="Maximum number of runs to cancel/apply in one execution.",
)
+ parser.add_argument(
+ "--priority-branch-prefix",
+ action="append",
+ default=[],
+ help=(
+ "Branch prefix to prioritize (repeatable). "
+ "When present in queue, non-matching runs of the same workflow become cancel candidates."
+ ),
+ )
parser.add_argument(
"--apply",
action="store_true",
@@ -165,7 +183,13 @@ def parse_timestamp(value: str | None) -> datetime:
return datetime.fromtimestamp(0, tz=timezone.utc)
-def run_identity_key(run: dict[str, Any]) -> tuple[str, str, str, str]:
+def branch_has_prefix(branch: str, prefixes: set[str]) -> bool:
+ if not branch:
+ return False
+ return any(branch.startswith(prefix) for prefix in prefixes)
+
+
+def run_identity_key(run: dict[str, Any], *, non_pr_key: str) -> tuple[str, str, str, str]:
name = str(run.get("name", ""))
event = str(run.get("event", ""))
head_branch = str(run.get("head_branch", ""))
@@ -179,7 +203,10 @@ def run_identity_key(run: dict[str, Any]) -> tuple[str, str, str, str]:
if pr_number:
# For PR traffic, cancel stale runs across synchronize updates for the same PR.
return (name, event, f"pr:{pr_number}", "")
- # For push/manual traffic, key by SHA to avoid collapsing distinct commits.
+ if non_pr_key == "branch":
+ # Branch-level supersedence for push/manual lanes.
+ return (name, event, head_branch, "")
+ # SHA-level supersedence for push/manual lanes.
return (name, event, head_branch, head_sha)
@@ -189,6 +216,8 @@ def collect_candidates(
dedupe_workflows: set[str],
*,
include_non_pr: bool,
+ non_pr_key: str,
+ priority_branch_prefixes: set[str],
) -> tuple[list[dict[str, Any]], Counter[str]]:
reasons_by_id: dict[int, set[str]] = defaultdict(set)
runs_by_id: dict[int, dict[str, Any]] = {}
@@ -205,6 +234,31 @@ def collect_candidates(
if str(run.get("name", "")) in obsolete_workflows:
reasons_by_id[run_id].add("obsolete-workflow")
+ if priority_branch_prefixes:
+ prioritized_workflows: set[str] = set()
+ for run in runs:
+ branch = str(run.get("head_branch", ""))
+ if branch_has_prefix(branch, priority_branch_prefixes):
+ workflow = str(run.get("name", ""))
+ if workflow:
+ prioritized_workflows.add(workflow)
+
+ for run in runs:
+ run_id_raw = run.get("id")
+ if run_id_raw is None:
+ continue
+ try:
+ run_id = int(run_id_raw)
+ except (TypeError, ValueError):
+ continue
+ workflow = str(run.get("name", ""))
+ if workflow not in prioritized_workflows:
+ continue
+ branch = str(run.get("head_branch", ""))
+ if branch_has_prefix(branch, priority_branch_prefixes):
+ continue
+ reasons_by_id[run_id].add("priority-preempted-by-release")
+
by_workflow: dict[str, dict[tuple[str, str, str, str], list[dict[str, Any]]]] = defaultdict(
lambda: defaultdict(list)
)
@@ -220,7 +274,7 @@ def collect_candidates(
has_pr_context = isinstance(pull_requests, list) and len(pull_requests) > 0
if is_pr_event and not has_pr_context and not include_non_pr:
continue
- key = run_identity_key(run)
+ key = run_identity_key(run, non_pr_key=non_pr_key)
by_workflow[name][key].append(run)
for groups in by_workflow.values():
@@ -299,15 +353,23 @@ def main() -> int:
obsolete_workflows = normalize_values(args.obsolete_workflow)
dedupe_workflows = normalize_values(args.dedupe_workflow)
- if not obsolete_workflows and not dedupe_workflows:
+ priority_prefixes = normalize_values(args.priority_branch_prefix)
+ if not obsolete_workflows and not dedupe_workflows and not priority_prefixes:
print(
- "queue_hygiene: no policy configured. Provide --obsolete-workflow and/or --dedupe-workflow.",
+ "queue_hygiene: no policy configured. Provide --obsolete-workflow, --dedupe-workflow, and/or --priority-branch-prefix.",
file=sys.stderr,
)
return 2
owner, repo = split_repo(args.repo)
token = resolve_token(args.token)
+ if args.apply and not token:
+ print(
+ "queue_hygiene: apply mode requires authentication token "
+ "(set GH_TOKEN/GITHUB_TOKEN, pass --token, or configure gh auth).",
+ file=sys.stderr,
+ )
+ return 2
api = GitHubApi(args.api_url, token)
if args.runs_json:
@@ -324,6 +386,8 @@ def main() -> int:
obsolete_workflows,
dedupe_workflows,
include_non_pr=args.dedupe_include_non_pr,
+ non_pr_key=args.non_pr_key,
+ priority_branch_prefixes=priority_prefixes,
)
capped = selected[: max(0, args.max_cancel)]
@@ -338,6 +402,8 @@ def main() -> int:
"obsolete_workflows": sorted(obsolete_workflows),
"dedupe_workflows": sorted(dedupe_workflows),
"dedupe_include_non_pr": args.dedupe_include_non_pr,
+ "non_pr_key": args.non_pr_key,
+ "priority_branch_prefixes": sorted(priority_prefixes),
"max_cancel": args.max_cancel,
},
"counts": {
diff --git a/scripts/ci/release_trigger_guard.py b/scripts/ci/release_trigger_guard.py
index c78c97738..37eb27793 100644
--- a/scripts/ci/release_trigger_guard.py
+++ b/scripts/ci/release_trigger_guard.py
@@ -79,6 +79,18 @@ def build_markdown(report: dict) -> str:
lines.append(f"- Tag version: `{metadata.get('tag_version')}`")
lines.append("")
+ ci_gate = report.get("ci_gate", {})
+ if ci_gate.get("ci_status"):
+ lines.append("## CI Gate")
+ lines.append(f"- CI status: `{ci_gate['ci_status']}`")
+ lines.append("")
+
+ dry_run_gate = report.get("dry_run_gate", {})
+ if dry_run_gate.get("prior_successful_runs") is not None:
+ lines.append("## Dry Run Gate")
+ lines.append(f"- Prior successful runs: `{dry_run_gate['prior_successful_runs']}`")
+ lines.append("")
+
if report["violations"]:
lines.append("## Violations")
for item in report["violations"]:
@@ -139,6 +151,8 @@ def main() -> int:
tagger_date: str | None = None
cargo_version: str | None = None
tag_version: str | None = None
+ ci_status: str | None = None
+ dry_run_count: int | None = None
if publish_release:
stable_match = STABLE_TAG_RE.fullmatch(args.release_tag)
@@ -169,12 +183,23 @@ def main() -> int:
)
origin_url = args.origin_url.strip() or f"https://github.com/{args.repository}.git"
- ls_remote = subprocess.run(
- ["git", "ls-remote", "--tags", origin_url],
- text=True,
- capture_output=True,
- check=False,
- )
+
+ # Prefer ls-remote from repo_root (inherits checkout auth headers) over
+ # a bare URL which fails on private repos.
+ if (repo_root / ".git").exists():
+ ls_remote = subprocess.run(
+ ["git", "-C", str(repo_root), "ls-remote", "--tags", "origin"],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ else:
+ ls_remote = subprocess.run(
+ ["git", "ls-remote", "--tags", origin_url],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
if ls_remote.returncode != 0:
violations.append(f"Failed to list origin tags from `{origin_url}`: {ls_remote.stderr.strip()}")
else:
@@ -211,6 +236,21 @@ def main() -> int:
try:
run_git(["init", "-q"], cwd=tmp_repo)
run_git(["remote", "add", "origin", origin_url], cwd=tmp_repo)
+ # Propagate auth extraheader from checkout so fetch works
+ # on private repos where bare URL access is forbidden.
+ if (repo_root / ".git").exists():
+ try:
+ extraheader = run_git(
+ ["config", "--get", "http.https://github.com/.extraheader"],
+ cwd=repo_root,
+ )
+ if extraheader:
+ run_git(
+ ["config", "http.https://github.com/.extraheader", extraheader],
+ cwd=tmp_repo,
+ )
+ except RuntimeError:
+ pass # No extraheader configured; proceed without it.
run_git(
[
"fetch",
@@ -293,6 +333,105 @@ def main() -> int:
except RuntimeError as exc:
warnings.append(f"Failed to inspect tagger metadata for `{args.release_tag}`: {exc}")
+ # --- CI green gate (blocking) ---
+ if tag_commit:
+ ci_check_proc = None
+ try:
+ ci_check_proc = subprocess.run(
+ [
+ "gh", "api",
+ f"repos/{args.repository}/commits/{tag_commit}/check-runs",
+ "--jq",
+ '[.check_runs[] | select(.name == "CI Required Gate")] | '
+ 'if length == 0 then "not_found" '
+ 'elif .[0].conclusion == "success" then "success" '
+ 'elif .[0].status != "completed" then "pending" '
+ 'else .[0].conclusion end',
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ ci_status = ci_check_proc.stdout.strip() if ci_check_proc.returncode == 0 else "api_error"
+ except FileNotFoundError:
+ ci_status = "gh_not_found"
+ warnings.append(
+ "gh CLI not found; CI status check skipped. "
+ "Install gh to enable CI gate enforcement."
+ )
+
+ if ci_status == "success":
+ pass # CI passed on the tagged commit
+ elif ci_status == "not_found":
+ violations.append(
+ f"CI Required Gate check-run not found for commit {tag_commit}. "
+ "Ensure ci-run.yml has completed on main before tagging."
+ )
+ elif ci_status == "pending":
+ violations.append(
+ f"CI is still running on commit {tag_commit}. "
+ "Wait for CI Required Gate to complete before publishing."
+ )
+ elif ci_status == "api_error":
+ ci_err = ci_check_proc.stderr.strip() if ci_check_proc else ""
+ msg = f"Could not query CI status for commit {tag_commit}: {ci_err}"
+ if "No commit found" in ci_err or "HTTP 422" in ci_err:
+ # Commit SHA not recognized by GitHub (e.g. test environment
+ # with local-only commits). Downgrade to warning.
+ warnings.append(f"{msg}. Commit not found on remote; CI gate skipped.")
+ elif publish_release:
+ violations.append(f"{msg}. Failing closed because CI gate could not be verified.")
+ else:
+ warnings.append(msg)
+ elif ci_status == "gh_not_found":
+ if publish_release:
+ violations.append(
+ "gh CLI not found; cannot enforce CI Required Gate in publish mode."
+ )
+ # verify mode: already handled as warning in except block
+ else:
+ violations.append(
+ f"CI Required Gate conclusion is '{ci_status}' (expected 'success') "
+ f"for commit {tag_commit}."
+ )
+
+ # --- Dry run verification gate (advisory) ---
+ if tag_commit:
+ try:
+ dry_run_proc = subprocess.run(
+ [
+ "gh", "api",
+ f"repos/{args.repository}/actions/workflows/pub-release.yml/runs"
+ f"?head_sha={tag_commit}&status=completed&conclusion=success&per_page=1",
+ "--jq",
+ ".total_count",
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+ dry_run_count_str = dry_run_proc.stdout.strip() if dry_run_proc.returncode == 0 else ""
+ except FileNotFoundError:
+ dry_run_count_str = ""
+ warnings.append(
+ "gh CLI not found; dry-run history check skipped."
+ )
+ try:
+ dry_run_count = int(dry_run_count_str)
+ except ValueError:
+ dry_run_count = -1
+
+ if dry_run_count == -1:
+ warnings.append(
+ f"Could not query dry-run history for commit {tag_commit}. "
+ "Manual verification recommended."
+ )
+ elif dry_run_count == 0:
+ warnings.append(
+ f"No prior successful pub-release.yml run found for commit {tag_commit}. "
+ "Consider running a verification build (publish_release=false) first."
+ )
+
if authorized_tagger_emails:
normalized_tagger = normalize_email(tagger_email or "")
if not normalized_tagger:
@@ -347,6 +486,13 @@ def main() -> int:
"tag_version": tag_version,
"cargo_version": cargo_version,
},
+ "ci_gate": {
+ "tag_commit": tag_commit,
+ "ci_status": ci_status if publish_release and tag_commit else None,
+ },
+ "dry_run_gate": {
+ "prior_successful_runs": dry_run_count if publish_release and tag_commit else None,
+ },
"trigger_provenance": {
"repository": args.repository,
"origin_url": args.origin_url.strip() or f"https://github.com/{args.repository}.git",
diff --git a/scripts/ci/reproducible_build_check.sh b/scripts/ci/reproducible_build_check.sh
index afbc38204..93b6647bd 100755
--- a/scripts/ci/reproducible_build_check.sh
+++ b/scripts/ci/reproducible_build_check.sh
@@ -6,16 +6,35 @@ set -euo pipefail
# - Compare artifact SHA256
# - Emit JSON + markdown artifacts for auditability
-PROFILE="${PROFILE:-release-fast}"
+PROFILE="${PROFILE:-release}"
BINARY_NAME="${BINARY_NAME:-zeroclaw}"
OUTPUT_DIR="${OUTPUT_DIR:-artifacts}"
FAIL_ON_DRIFT="${FAIL_ON_DRIFT:-false}"
ALLOW_BUILD_ID_DRIFT="${ALLOW_BUILD_ID_DRIFT:-true}"
+TARGET_ROOT="${CARGO_TARGET_DIR:-target}"
mkdir -p "${OUTPUT_DIR}"
host_target="$(rustc -vV | sed -n 's/^host: //p')"
-artifact_path="target/${host_target}/${PROFILE}/${BINARY_NAME}"
+artifact_path="${TARGET_ROOT}/${host_target}/${PROFILE}/${BINARY_NAME}"
+
+sha256_file() {
+ local file="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "${file}" | awk '{print $1}'
+ return 0
+ fi
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 "${file}" | awk '{print $1}'
+ return 0
+ fi
+ if command -v openssl >/dev/null 2>&1; then
+ openssl dgst -sha256 "${file}" | awk '{print $NF}'
+ return 0
+ fi
+ echo "no SHA256 tool found (need sha256sum, shasum, or openssl)" >&2
+ exit 5
+}
build_once() {
local pass="$1"
@@ -26,7 +45,7 @@ build_once() {
exit 2
fi
cp "${artifact_path}" "${OUTPUT_DIR}/repro-build-${pass}.bin"
- sha256sum "${OUTPUT_DIR}/repro-build-${pass}.bin" | awk '{print $1}'
+ sha256_file "${OUTPUT_DIR}/repro-build-${pass}.bin"
}
extract_build_id() {
diff --git a/scripts/ci/rust_quality_gate.sh b/scripts/ci/rust_quality_gate.sh
index 75e7f1dae..121204c3a 100755
--- a/scripts/ci/rust_quality_gate.sh
+++ b/scripts/ci/rust_quality_gate.sh
@@ -7,13 +7,73 @@ if [ "${1:-}" = "--strict" ]; then
MODE="strict"
fi
-echo "==> rust quality: cargo fmt --all -- --check"
-cargo fmt --all -- --check
+ensure_toolchain_bin_on_path() {
+ local toolchain_bin=""
+ if [ -n "${CARGO:-}" ]; then
+ toolchain_bin="$(dirname "${CARGO}")"
+ elif [ -n "${RUSTC:-}" ]; then
+ toolchain_bin="$(dirname "${RUSTC}")"
+ fi
+
+ if [ -z "$toolchain_bin" ] || [ ! -d "$toolchain_bin" ]; then
+ return 0
+ fi
+
+ case ":$PATH:" in
+ *":${toolchain_bin}:"*) ;;
+ *) export PATH="${toolchain_bin}:$PATH" ;;
+ esac
+}
+
+ensure_toolchain_bin_on_path
+
+run_cargo_tool() {
+ local subcommand="$1"
+ shift
+
+ if [ -n "${RUSTUP_TOOLCHAIN:-}" ] && command -v rustup >/dev/null 2>&1; then
+ rustup run "${RUSTUP_TOOLCHAIN}" cargo "$subcommand" "$@"
+ else
+ cargo "$subcommand" "$@"
+ fi
+}
+
+ensure_cargo_subcommand_component() {
+ local subcommand="$1"
+ local toolchain="${RUSTUP_TOOLCHAIN:-}"
+ local component="$subcommand"
+
+ if [ "$subcommand" = "fmt" ]; then
+ component="rustfmt"
+ fi
+
+ if run_cargo_tool "$subcommand" --version >/dev/null 2>&1; then
+ return 0
+ fi
+
+ if ! command -v rustup >/dev/null 2>&1; then
+ echo "::error::cargo ${subcommand} is unavailable and rustup is not installed."
+ return 1
+ fi
+
+ echo "==> rust quality: installing missing rust component '${component}'"
+ if [ -n "$toolchain" ]; then
+ rustup component add "$component" --toolchain "$toolchain"
+ else
+ rustup component add "$component"
+ fi
+}
+
+ensure_cargo_subcommand_component "fmt"
+echo "==> rust quality: cargo fmt --all -- --check"
+run_cargo_tool fmt --all -- --check
+
+ensure_cargo_subcommand_component "clippy"
if [ "$MODE" = "strict" ]; then
echo "==> rust quality: cargo clippy --locked --all-targets -- -D warnings"
- cargo clippy --locked --all-targets -- -D warnings
+ run_cargo_tool clippy --locked --all-targets -- -D warnings
else
echo "==> rust quality: cargo clippy --locked --all-targets -- -D clippy::correctness"
- cargo clippy --locked --all-targets -- -D clippy::correctness
+ run_cargo_tool clippy --locked --all-targets -- -D clippy::correctness
fi
diff --git a/scripts/ci/rust_strict_delta_gate.sh b/scripts/ci/rust_strict_delta_gate.sh
index 5f4ccc7f6..3b306e14a 100755
--- a/scripts/ci/rust_strict_delta_gate.sh
+++ b/scripts/ci/rust_strict_delta_gate.sh
@@ -5,6 +5,38 @@ set -euo pipefail
BASE_SHA="${BASE_SHA:-}"
RUST_FILES_RAW="${RUST_FILES:-}"
+ensure_toolchain_bin_on_path() {
+ local toolchain_bin=""
+
+ if [ -n "${CARGO:-}" ]; then
+ toolchain_bin="$(dirname "${CARGO}")"
+ elif [ -n "${RUSTC:-}" ]; then
+ toolchain_bin="$(dirname "${RUSTC}")"
+ fi
+
+ if [ -z "$toolchain_bin" ] || [ ! -d "$toolchain_bin" ]; then
+ return 0
+ fi
+
+ case ":$PATH:" in
+ *":${toolchain_bin}:"*) ;;
+ *) export PATH="${toolchain_bin}:$PATH" ;;
+ esac
+}
+
+run_cargo_tool() {
+ local subcommand="$1"
+ shift
+
+ if [ -n "${RUSTUP_TOOLCHAIN:-}" ] && command -v rustup >/dev/null 2>&1; then
+ rustup run "${RUSTUP_TOOLCHAIN}" cargo "$subcommand" "$@"
+ else
+ cargo "$subcommand" "$@"
+ fi
+}
+
+ensure_toolchain_bin_on_path
+
if [ -z "$BASE_SHA" ] && git rev-parse --verify origin/main >/dev/null 2>&1; then
BASE_SHA="$(git merge-base origin/main HEAD)"
fi
@@ -88,7 +120,7 @@ print(json.dumps(changed))
PY
set +e
-cargo clippy --quiet --locked --all-targets --message-format=json -- -D warnings >"$CLIPPY_JSON_FILE" 2>"$CLIPPY_STDERR_FILE"
+run_cargo_tool clippy --quiet --locked --all-targets --message-format=json -- -D warnings >"$CLIPPY_JSON_FILE" 2>"$CLIPPY_STDERR_FILE"
CLIPPY_EXIT=$?
set -e
diff --git a/scripts/ci/self_heal_rust_toolchain.sh b/scripts/ci/self_heal_rust_toolchain.sh
new file mode 100755
index 000000000..0caef474c
--- /dev/null
+++ b/scripts/ci/self_heal_rust_toolchain.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Remove corrupted toolchain installs that can break rustc startup on long-lived runners.
+# Usage: ./scripts/ci/self_heal_rust_toolchain.sh [toolchain]
+
+TOOLCHAIN="${1:-1.92.0}"
+
+# Use per-job Rust homes on self-hosted runners to avoid cross-runner corruption/races.
+if [ -n "${RUNNER_TEMP:-}" ]; then
+ CARGO_HOME="${RUNNER_TEMP%/}/cargo-home"
+ RUSTUP_HOME="${RUNNER_TEMP%/}/rustup-home"
+ mkdir -p "${CARGO_HOME}" "${RUSTUP_HOME}"
+ export CARGO_HOME RUSTUP_HOME
+ export PATH="${CARGO_HOME}/bin:${PATH}"
+ if [ -n "${GITHUB_ENV:-}" ]; then
+ {
+ echo "CARGO_HOME=${CARGO_HOME}"
+ echo "RUSTUP_HOME=${RUSTUP_HOME}"
+ } >> "${GITHUB_ENV}"
+ fi
+ if [ -n "${GITHUB_PATH:-}" ]; then
+ echo "${CARGO_HOME}/bin" >> "${GITHUB_PATH}"
+ fi
+fi
+
+if ! command -v rustup >/dev/null 2>&1; then
+ echo "rustup not installed yet; skipping rust toolchain self-heal."
+ exit 0
+fi
+
+if rustc "+${TOOLCHAIN}" --version >/dev/null 2>&1 && cargo "+${TOOLCHAIN}" --version >/dev/null 2>&1; then
+ echo "Rust toolchain ${TOOLCHAIN} is healthy (rustc + cargo present)."
+ exit 0
+fi
+
+echo "Rust toolchain ${TOOLCHAIN} appears unhealthy (missing rustc/cargo); removing cached installs."
+for candidate in \
+ "${TOOLCHAIN}" \
+ "${TOOLCHAIN}-x86_64-apple-darwin" \
+ "${TOOLCHAIN}-aarch64-apple-darwin" \
+ "${TOOLCHAIN}-x86_64-unknown-linux-gnu" \
+ "${TOOLCHAIN}-aarch64-unknown-linux-gnu"
+do
+ rustup toolchain uninstall "${candidate}" >/dev/null 2>&1 || true
+done
diff --git a/scripts/ci/smoke_build_retry.sh b/scripts/ci/smoke_build_retry.sh
new file mode 100644
index 000000000..35b0c7fd8
--- /dev/null
+++ b/scripts/ci/smoke_build_retry.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+attempts="${CI_SMOKE_BUILD_ATTEMPTS:-3}"
+
+if ! [[ "$attempts" =~ ^[0-9]+$ ]] || [ "$attempts" -lt 1 ]; then
+ echo "::error::CI_SMOKE_BUILD_ATTEMPTS must be a positive integer (got: ${attempts})" >&2
+ exit 2
+fi
+
+IFS=',' read -r -a retryable_codes <<< "${CI_SMOKE_RETRY_CODES:-143,137}"
+
+is_retryable_code() {
+ local code="$1"
+ local candidate=""
+ for candidate in "${retryable_codes[@]}"; do
+ candidate="${candidate//[[:space:]]/}"
+ if [ "$candidate" = "$code" ]; then
+ return 0
+ fi
+ done
+ return 1
+}
+
+build_cmd=(cargo build --package zeroclaw --bin zeroclaw --profile release-fast --locked)
+
+attempt=1
+while [ "$attempt" -le "$attempts" ]; do
+ echo "::group::Smoke build attempt ${attempt}/${attempts}"
+ echo "Running: ${build_cmd[*]}"
+ set +e
+ "${build_cmd[@]}"
+ code=$?
+ set -e
+ echo "::endgroup::"
+
+ if [ "$code" -eq 0 ]; then
+ echo "Smoke build succeeded on attempt ${attempt}/${attempts}."
+ exit 0
+ fi
+
+ if [ "$attempt" -ge "$attempts" ] || ! is_retryable_code "$code"; then
+ echo "::error::Smoke build failed with exit code ${code} on attempt ${attempt}/${attempts}."
+ exit "$code"
+ fi
+
+ echo "::warning::Smoke build exited with ${code} (transient runner interruption suspected). Retrying..."
+ sleep 10
+ attempt=$((attempt + 1))
+done
+
+echo "::error::Smoke build did not complete successfully."
+exit 1
diff --git a/scripts/ci/tests/test_agent_team_orchestration_eval.py b/scripts/ci/tests/test_agent_team_orchestration_eval.py
new file mode 100644
index 000000000..eecb62ab5
--- /dev/null
+++ b/scripts/ci/tests/test_agent_team_orchestration_eval.py
@@ -0,0 +1,255 @@
+#!/usr/bin/env python3
+"""Tests for scripts/ci/agent_team_orchestration_eval.py."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[3]
+SCRIPT = ROOT / "scripts" / "ci" / "agent_team_orchestration_eval.py"
+
+
+def run_cmd(cmd: list[str]) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ cmd,
+ cwd=str(ROOT),
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+
+class AgentTeamOrchestrationEvalTest(unittest.TestCase):
+ maxDiff = None
+
+ def test_json_output_contains_expected_fields(self) -> None:
+ with tempfile.NamedTemporaryFile(suffix=".json") as out:
+ proc = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--json-output",
+ out.name,
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+
+ payload = json.loads(Path(out.name).read_text(encoding="utf-8"))
+ self.assertEqual(payload["schema_version"], "zeroclaw.agent-team-eval.v1")
+ self.assertEqual(payload["budget_profile"], "medium")
+ self.assertIn("results", payload)
+ self.assertEqual(len(payload["results"]), 4)
+ self.assertIn("recommendation", payload)
+
+ sample = payload["results"][0]
+ required_keys = {
+ "topology",
+ "participants",
+ "model_tier",
+ "tasks",
+ "execution_tokens",
+ "coordination_tokens",
+ "cache_savings_tokens",
+ "total_tokens",
+ "coordination_ratio",
+ "estimated_pass_rate",
+ "estimated_defect_escape",
+ "estimated_p95_latency_s",
+ "estimated_throughput_tpd",
+ "budget_limit_tokens",
+ "budget_ok",
+ "gates",
+ "gate_pass",
+ }
+ self.assertTrue(required_keys.issubset(sample.keys()))
+
+ def test_coordination_ratio_increases_with_topology_complexity(self) -> None:
+ proc = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+ payload = json.loads(proc.stdout)
+
+ by_topology = {row["topology"]: row for row in payload["results"]}
+ self.assertLess(
+ by_topology["single"]["coordination_ratio"],
+ by_topology["lead_subagent"]["coordination_ratio"],
+ )
+ self.assertLess(
+ by_topology["lead_subagent"]["coordination_ratio"],
+ by_topology["star_team"]["coordination_ratio"],
+ )
+ self.assertLess(
+ by_topology["star_team"]["coordination_ratio"],
+ by_topology["mesh_team"]["coordination_ratio"],
+ )
+
+ def test_protocol_transcript_costs_more_coordination_tokens(self) -> None:
+ base = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--topologies",
+ "star_team",
+ "--protocol-mode",
+ "a2a_lite",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(base.returncode, 0, msg=base.stderr)
+ base_payload = json.loads(base.stdout)
+
+ transcript = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--topologies",
+ "star_team",
+ "--protocol-mode",
+ "transcript",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(transcript.returncode, 0, msg=transcript.stderr)
+ transcript_payload = json.loads(transcript.stdout)
+
+ base_tokens = base_payload["results"][0]["coordination_tokens"]
+ transcript_tokens = transcript_payload["results"][0]["coordination_tokens"]
+ self.assertGreater(transcript_tokens, base_tokens)
+
+ def test_auto_degradation_applies_under_pressure(self) -> None:
+ no_degrade = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--topologies",
+ "mesh_team",
+ "--degradation-policy",
+ "none",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(no_degrade.returncode, 0, msg=no_degrade.stderr)
+ no_degrade_payload = json.loads(no_degrade.stdout)
+ no_degrade_row = no_degrade_payload["results"][0]
+
+ auto_degrade = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--topologies",
+ "mesh_team",
+ "--degradation-policy",
+ "auto",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(auto_degrade.returncode, 0, msg=auto_degrade.stderr)
+ auto_payload = json.loads(auto_degrade.stdout)
+ auto_row = auto_payload["results"][0]
+
+ self.assertTrue(auto_row["degradation_applied"])
+ self.assertLess(auto_row["participants"], no_degrade_row["participants"])
+ self.assertLess(auto_row["coordination_tokens"], no_degrade_row["coordination_tokens"])
+
+ def test_all_budgets_emits_budget_sweep(self) -> None:
+ proc = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--all-budgets",
+ "--topologies",
+ "single,star_team",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+ payload = json.loads(proc.stdout)
+ self.assertIn("budget_sweep", payload)
+ self.assertEqual(len(payload["budget_sweep"]), 3)
+ budgets = [x["budget_profile"] for x in payload["budget_sweep"]]
+ self.assertEqual(budgets, ["low", "medium", "high"])
+
+ def test_gate_fails_for_mesh_under_default_threshold(self) -> None:
+ proc = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--topologies",
+ "mesh_team",
+ "--enforce-gates",
+ "--max-coordination-ratio",
+ "0.20",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(proc.returncode, 1)
+ self.assertIn("gate violations detected", proc.stderr)
+ self.assertIn("mesh_team", proc.stderr)
+
+ def test_gate_passes_for_star_under_default_threshold(self) -> None:
+ proc = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--topologies",
+ "star_team",
+ "--enforce-gates",
+ "--max-coordination-ratio",
+ "0.20",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+
+ def test_recommendation_prefers_star_for_medium_defaults(self) -> None:
+ proc = run_cmd(
+ [
+ "python3",
+ str(SCRIPT),
+ "--budget",
+ "medium",
+ "--json-output",
+ "-",
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+ payload = json.loads(proc.stdout)
+ self.assertEqual(payload["recommendation"]["recommended_topology"], "star_team")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/ci/tests/test_ci_scripts.py b/scripts/ci/tests/test_ci_scripts.py
index 1e5c7921a..2b9c8ae67 100644
--- a/scripts/ci/tests/test_ci_scripts.py
+++ b/scripts/ci/tests/test_ci_scripts.py
@@ -7,6 +7,7 @@ import contextlib
import hashlib
import http.server
import json
+import os
import shutil
import socket
import socketserver
@@ -409,6 +410,79 @@ class CiScriptsBehaviorTest(unittest.TestCase):
report = json.loads(out_json.read_text(encoding="utf-8"))
self.assertEqual(report["classification"], "persistent_failure")
+ def test_smoke_build_retry_retries_transient_143_once(self) -> None:
+ fake_bin = self.tmp / "fake-bin"
+ fake_bin.mkdir(parents=True, exist_ok=True)
+ counter = self.tmp / "cargo-counter.txt"
+
+ fake_cargo = fake_bin / "cargo"
+ fake_cargo.write_text(
+ textwrap.dedent(
+ """\
+ #!/usr/bin/env bash
+ set -euo pipefail
+ counter="${FAKE_CARGO_COUNTER:?}"
+ attempts=0
+ if [ -f "$counter" ]; then
+ attempts="$(cat "$counter")"
+ fi
+ attempts="$((attempts + 1))"
+ printf '%s' "$attempts" > "$counter"
+ if [ "$attempts" -eq 1 ]; then
+ exit 143
+ fi
+ exit 0
+ """
+ ),
+ encoding="utf-8",
+ )
+ fake_cargo.chmod(0o755)
+
+ env = dict(os.environ)
+ env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}"
+ env["FAKE_CARGO_COUNTER"] = str(counter)
+ env["CI_SMOKE_BUILD_ATTEMPTS"] = "2"
+
+ proc = run_cmd(["bash", self._script("smoke_build_retry.sh")], env=env, cwd=ROOT)
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+ self.assertEqual(counter.read_text(encoding="utf-8"), "2")
+ self.assertIn("Retrying", proc.stdout)
+
+ def test_smoke_build_retry_fails_immediately_on_non_retryable_code(self) -> None:
+ fake_bin = self.tmp / "fake-bin"
+ fake_bin.mkdir(parents=True, exist_ok=True)
+ counter = self.tmp / "cargo-counter.txt"
+
+ fake_cargo = fake_bin / "cargo"
+ fake_cargo.write_text(
+ textwrap.dedent(
+ """\
+ #!/usr/bin/env bash
+ set -euo pipefail
+ counter="${FAKE_CARGO_COUNTER:?}"
+ attempts=0
+ if [ -f "$counter" ]; then
+ attempts="$(cat "$counter")"
+ fi
+ attempts="$((attempts + 1))"
+ printf '%s' "$attempts" > "$counter"
+ exit 101
+ """
+ ),
+ encoding="utf-8",
+ )
+ fake_cargo.chmod(0o755)
+
+ env = dict(os.environ)
+ env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}"
+ env["FAKE_CARGO_COUNTER"] = str(counter)
+ env["CI_SMOKE_BUILD_ATTEMPTS"] = "3"
+
+ proc = run_cmd(["bash", self._script("smoke_build_retry.sh")], env=env, cwd=ROOT)
+ self.assertEqual(proc.returncode, 101)
+ self.assertEqual(counter.read_text(encoding="utf-8"), "1")
+ self.assertIn("failed with exit code 101", proc.stdout)
+
def test_deny_policy_guard_detects_invalid_entries(self) -> None:
deny_path = self.tmp / "deny.toml"
deny_path.write_text(
@@ -3759,6 +3833,255 @@ class CiScriptsBehaviorTest(unittest.TestCase):
planned_ids = [item["id"] for item in report["planned_actions"]]
self.assertEqual(planned_ids, [101, 102])
+ def test_queue_hygiene_priority_branch_prefix_preempts_non_release_runs(self) -> None:
+ runs_json = self.tmp / "runs-priority-release.json"
+ output_json = self.tmp / "queue-hygiene-priority-release.json"
+ runs_json.write_text(
+ json.dumps(
+ {
+ "workflow_runs": [
+ {
+ "id": 501,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "release/v0.2.0",
+ "head_sha": "sha-501",
+ "created_at": "2026-02-27T20:00:00Z",
+ },
+ {
+ "id": 502,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "feature-fast-path",
+ "head_sha": "sha-502",
+ "created_at": "2026-02-27T20:01:00Z",
+ },
+ {
+ "id": 503,
+ "name": "Sec CodeQL",
+ "event": "pull_request",
+ "head_branch": "feature-a",
+ "head_sha": "sha-503",
+ "created_at": "2026-02-27T20:02:00Z",
+ "pull_requests": [{"number": 2001}],
+ },
+ {
+ "id": 504,
+ "name": "Sec CodeQL",
+ "event": "pull_request",
+ "head_branch": "release/v0.2.0",
+ "head_sha": "sha-504",
+ "created_at": "2026-02-27T20:03:00Z",
+ "pull_requests": [{"number": 2002}],
+ },
+ {
+ "id": 505,
+ "name": "Security Audit",
+ "event": "push",
+ "head_branch": "feature-only",
+ "head_sha": "sha-505",
+ "created_at": "2026-02-27T20:04:00Z",
+ },
+ ]
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ proc = run_cmd(
+ [
+ "python3",
+ self._script("queue_hygiene.py"),
+ "--runs-json",
+ str(runs_json),
+ "--priority-branch-prefix",
+ "release/",
+ "--output-json",
+ str(output_json),
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+
+ report = json.loads(output_json.read_text(encoding="utf-8"))
+ planned_ids = [item["id"] for item in report["planned_actions"]]
+ self.assertEqual(planned_ids, [502, 503])
+ reasons_by_id = {item["id"]: item["reasons"] for item in report["planned_actions"]}
+ self.assertIn("priority-preempted-by-release", reasons_by_id[502])
+ self.assertIn("priority-preempted-by-release", reasons_by_id[503])
+ self.assertEqual(report["policies"]["priority_branch_prefixes"], ["release/"])
+
+ def test_queue_hygiene_non_pr_branch_mode_dedupes_push_runs(self) -> None:
+ runs_json = self.tmp / "runs-non-pr-branch.json"
+ output_json = self.tmp / "queue-hygiene-non-pr-branch.json"
+ runs_json.write_text(
+ json.dumps(
+ {
+ "workflow_runs": [
+ {
+ "id": 201,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "main",
+ "head_sha": "sha-201",
+ "created_at": "2026-02-27T20:00:00Z",
+ },
+ {
+ "id": 202,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "main",
+ "head_sha": "sha-202",
+ "created_at": "2026-02-27T20:01:00Z",
+ },
+ {
+ "id": 203,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "dev",
+ "head_sha": "sha-203",
+ "created_at": "2026-02-27T20:02:00Z",
+ },
+ ]
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ proc = run_cmd(
+ [
+ "python3",
+ self._script("queue_hygiene.py"),
+ "--runs-json",
+ str(runs_json),
+ "--dedupe-workflow",
+ "CI Run",
+ "--dedupe-include-non-pr",
+ "--non-pr-key",
+ "branch",
+ "--output-json",
+ str(output_json),
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+
+ report = json.loads(output_json.read_text(encoding="utf-8"))
+ self.assertEqual(report["counts"]["candidate_runs_before_cap"], 1)
+ planned_ids = [item["id"] for item in report["planned_actions"]]
+ self.assertEqual(planned_ids, [201])
+ reasons = report["planned_actions"][0]["reasons"]
+ self.assertTrue(any(reason.startswith("dedupe-superseded-by:202") for reason in reasons))
+ self.assertEqual(report["policies"]["non_pr_key"], "branch")
+
+ def test_queue_hygiene_non_pr_sha_mode_keeps_distinct_push_commits(self) -> None:
+ runs_json = self.tmp / "runs-non-pr-sha.json"
+ output_json = self.tmp / "queue-hygiene-non-pr-sha.json"
+ runs_json.write_text(
+ json.dumps(
+ {
+ "workflow_runs": [
+ {
+ "id": 301,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "main",
+ "head_sha": "sha-301",
+ "created_at": "2026-02-27T20:00:00Z",
+ },
+ {
+ "id": 302,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "main",
+ "head_sha": "sha-302",
+ "created_at": "2026-02-27T20:01:00Z",
+ },
+ ]
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ proc = run_cmd(
+ [
+ "python3",
+ self._script("queue_hygiene.py"),
+ "--runs-json",
+ str(runs_json),
+ "--dedupe-workflow",
+ "CI Run",
+ "--dedupe-include-non-pr",
+ "--output-json",
+ str(output_json),
+ ]
+ )
+ self.assertEqual(proc.returncode, 0, msg=proc.stderr)
+
+ report = json.loads(output_json.read_text(encoding="utf-8"))
+ self.assertEqual(report["counts"]["candidate_runs_before_cap"], 0)
+ self.assertEqual(report["planned_actions"], [])
+ self.assertEqual(report["policies"]["non_pr_key"], "sha")
+
+ def test_queue_hygiene_apply_requires_authentication_token(self) -> None:
+ runs_json = self.tmp / "runs-apply-auth.json"
+ output_json = self.tmp / "queue-hygiene-apply-auth.json"
+ runs_json.write_text(
+ json.dumps(
+ {
+ "workflow_runs": [
+ {
+ "id": 401,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "main",
+ "head_sha": "sha-401",
+ "created_at": "2026-02-27T20:00:00Z",
+ },
+ {
+ "id": 402,
+ "name": "CI Run",
+ "event": "push",
+ "head_branch": "main",
+ "head_sha": "sha-402",
+ "created_at": "2026-02-27T20:01:00Z",
+ },
+ ]
+ }
+ )
+ + "\n",
+ encoding="utf-8",
+ )
+
+ isolated_home = self.tmp / "isolated-home"
+ isolated_home.mkdir(parents=True, exist_ok=True)
+ isolated_xdg = self.tmp / "isolated-xdg"
+ isolated_xdg.mkdir(parents=True, exist_ok=True)
+
+ env = dict(os.environ)
+ env["GH_TOKEN"] = ""
+ env["GITHUB_TOKEN"] = ""
+ env["HOME"] = str(isolated_home)
+ env["XDG_CONFIG_HOME"] = str(isolated_xdg)
+
+ proc = run_cmd(
+ [
+ "python3",
+ self._script("queue_hygiene.py"),
+ "--runs-json",
+ str(runs_json),
+ "--dedupe-workflow",
+ "CI Run",
+ "--apply",
+ "--output-json",
+ str(output_json),
+ ],
+ env=env,
+ )
+ self.assertEqual(proc.returncode, 2)
+ self.assertIn("requires authentication token", proc.stderr.lower())
+
if __name__ == "__main__": # pragma: no cover
unittest.main(verbosity=2)
diff --git a/scripts/ci/unsafe_debt_audit.py b/scripts/ci/unsafe_debt_audit.py
index 3e7801277..7eb2fd7f1 100755
--- a/scripts/ci/unsafe_debt_audit.py
+++ b/scripts/ci/unsafe_debt_audit.py
@@ -9,11 +9,15 @@ import json
import re
import subprocess
import sys
-import tomllib
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
+try:
+ import tomllib # Python 3.11+
+except ModuleNotFoundError:
+ import tomli as tomllib # type: ignore
+
@dataclass(frozen=True)
class PatternSpec:
diff --git a/scripts/install-release.sh b/scripts/install-release.sh
index d9d22452b..0151f670e 100755
--- a/scripts/install-release.sh
+++ b/scripts/install-release.sh
@@ -65,7 +65,7 @@ Usage: install-release.sh [--no-onboard]
Installs the latest Linux ZeroClaw binary from official GitHub releases.
Options:
- --no-onboard Install only; do not run `zeroclaw onboard`
+ --no-onboard Install only; do not run onboarding
Environment:
ZEROCLAW_INSTALL_DIR Override install directory
@@ -141,4 +141,9 @@ if [ "$NO_ONBOARD" -eq 1 ]; then
fi
echo "==> Starting onboarding"
+if [ -t 0 ] && [ -t 1 ]; then
+ exec "$BIN_PATH" onboard --interactive-ui
+fi
+
+echo "note: non-interactive shell detected; falling back to quick onboarding mode" >&2
exec "$BIN_PATH" onboard
diff --git a/scripts/pr-verify.sh b/scripts/pr-verify.sh
new file mode 100755
index 000000000..6ae9d6fb7
--- /dev/null
+++ b/scripts/pr-verify.sh
@@ -0,0 +1,120 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ scripts/pr-verify.sh [repo]
+
+Examples:
+ scripts/pr-verify.sh 2293
+ scripts/pr-verify.sh 2293 zeroclaw-labs/zeroclaw
+
+Description:
+ Verifies PR merge state using GitHub REST API (low-rate path) and
+ confirms merge commit ancestry against local git refs when possible.
+EOF
+}
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "error: required command not found: $1" >&2
+ exit 1
+ fi
+}
+
+format_epoch() {
+ local ts="${1:-}"
+ if [[ -z "$ts" || "$ts" == "null" ]]; then
+ echo "n/a"
+ return
+ fi
+
+ if date -r "$ts" "+%Y-%m-%d %H:%M:%S %Z" >/dev/null 2>&1; then
+ date -r "$ts" "+%Y-%m-%d %H:%M:%S %Z"
+ return
+ fi
+
+ if date -d "@$ts" "+%Y-%m-%d %H:%M:%S %Z" >/dev/null 2>&1; then
+ date -d "@$ts" "+%Y-%m-%d %H:%M:%S %Z"
+ return
+ fi
+
+ echo "$ts"
+}
+
+if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -lt 1 ]]; then
+ usage
+ exit 0
+fi
+
+PR_NUMBER="$1"
+REPO="${2:-zeroclaw-labs/zeroclaw}"
+BASE_REMOTE="${BASE_REMOTE:-origin}"
+
+require_cmd gh
+require_cmd git
+
+if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
+ echo "error: must be numeric (got: $PR_NUMBER)" >&2
+ exit 1
+fi
+
+echo "== PR Snapshot (REST) =="
+IFS=$'\t' read -r number title state merged merged_at merge_sha base_ref head_ref head_sha url < <(
+ gh api "repos/$REPO/pulls/$PR_NUMBER" \
+ --jq '[.number, .title, .state, (.merged|tostring), (.merged_at // ""), (.merge_commit_sha // ""), .base.ref, .head.ref, .head.sha, .html_url] | @tsv'
+)
+
+echo "repo: $REPO"
+echo "pr: #$number"
+echo "title: $title"
+echo "state: $state"
+echo "merged: $merged"
+echo "merged_at: ${merged_at:-n/a}"
+echo "base_ref: $base_ref"
+echo "head_ref: $head_ref"
+echo "head_sha: $head_sha"
+echo "merge_sha: ${merge_sha:-n/a}"
+echo "url: $url"
+
+echo
+echo "== API Buckets =="
+IFS=$'\t' read -r core_rem core_lim gql_rem gql_lim core_reset gql_reset < <(
+ gh api rate_limit \
+ --jq '[.resources.core.remaining, .resources.core.limit, .resources.graphql.remaining, .resources.graphql.limit, .resources.core.reset, .resources.graphql.reset] | @tsv'
+)
+
+echo "core: $core_rem/$core_lim (reset: $(format_epoch "$core_reset"))"
+echo "graphql: $gql_rem/$gql_lim (reset: $(format_epoch "$gql_reset"))"
+
+echo
+echo "== Git Ancestry Check =="
+if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ echo "local_repo: n/a (not inside a git worktree)"
+ exit 0
+fi
+
+echo "local_repo: $(git rev-parse --show-toplevel)"
+
+if [[ "$merged" != "true" || -z "$merge_sha" ]]; then
+ echo "result: skipped (PR not merged or merge commit unavailable)"
+ exit 0
+fi
+
+if ! git fetch "$BASE_REMOTE" "$base_ref" >/dev/null 2>&1; then
+ echo "result: unable to fetch $BASE_REMOTE/$base_ref (network/remote issue)"
+ exit 0
+fi
+
+if ! git rev-parse --verify "$BASE_REMOTE/$base_ref" >/dev/null 2>&1; then
+ echo "result: unable to resolve $BASE_REMOTE/$base_ref"
+ exit 0
+fi
+
+if git merge-base --is-ancestor "$merge_sha" "$BASE_REMOTE/$base_ref"; then
+ echo "result: PASS ($merge_sha is on $BASE_REMOTE/$base_ref)"
+else
+ echo "result: FAIL ($merge_sha not found on $BASE_REMOTE/$base_ref)"
+ exit 2
+fi
diff --git a/scripts/release/cut_release_tag.sh b/scripts/release/cut_release_tag.sh
index 612898307..f8722d28e 100755
--- a/scripts/release/cut_release_tag.sh
+++ b/scripts/release/cut_release_tag.sh
@@ -60,6 +60,55 @@ if [[ "$HEAD_SHA" != "$MAIN_SHA" ]]; then
exit 1
fi
+# --- CI green gate (blocks on pending/failure, warns on unavailable) ---
+echo "Checking CI status on HEAD ($HEAD_SHA)..."
+if command -v gh >/dev/null 2>&1; then
+ CI_STATUS="$(gh api "repos/$(gh repo view --json nameWithOwner --jq .nameWithOwner 2>/dev/null || echo 'zeroclaw-labs/zeroclaw')/commits/${HEAD_SHA}/check-runs" \
+ --jq '[.check_runs[] | select(.name == "CI Required Gate")] |
+ if length == 0 then "not_found"
+ elif .[0].conclusion == "success" then "success"
+ elif .[0].status != "completed" then "pending"
+ else .[0].conclusion end' 2>/dev/null || echo "api_error")"
+
+ case "$CI_STATUS" in
+ success)
+ echo "CI Required Gate: passed"
+ ;;
+ pending)
+ echo "error: CI is still running on $HEAD_SHA. Wait for CI Required Gate to complete." >&2
+ exit 1
+ ;;
+ not_found)
+ echo "warning: CI Required Gate check-run not found for $HEAD_SHA." >&2
+ echo "hint: ensure ci-run.yml has completed on main before cutting a release tag." >&2
+ ;;
+ api_error)
+ echo "warning: could not query GitHub API for CI status (gh CLI issue or auth)." >&2
+ echo "hint: CI status will be verified server-side by release_trigger_guard.py." >&2
+ ;;
+ *)
+ echo "error: CI Required Gate conclusion is '$CI_STATUS' (expected 'success')." >&2
+ exit 1
+ ;;
+ esac
+else
+ echo "warning: gh CLI not found; skipping local CI status check."
+ echo "hint: CI status will be verified server-side by release_trigger_guard.py."
+fi
+
+# --- Cargo.lock consistency pre-flight ---
+echo "Checking Cargo.lock consistency..."
+if command -v cargo >/dev/null 2>&1; then
+ if ! cargo check --locked --quiet; then
+ echo "error: cargo check --locked failed." >&2
+ echo "hint: if this is lockfile drift, run 'cargo check' and commit the updated Cargo.lock." >&2
+ exit 1
+ fi
+ echo "Cargo.lock: consistent"
+else
+ echo "warning: cargo not found; skipping Cargo.lock consistency check."
+fi
+
if git show-ref --tags --verify --quiet "refs/tags/$TAG"; then
echo "error: tag already exists locally: $TAG" >&2
exit 1
diff --git a/src/agent/agent.rs b/src/agent/agent.rs
index 3ecc2179e..abfc77bba 100644
--- a/src/agent/agent.rs
+++ b/src/agent/agent.rs
@@ -2,6 +2,7 @@ use crate::agent::dispatcher::{
NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
};
use crate::agent::loop_::detection::{DetectionVerdict, LoopDetectionConfig, LoopDetector};
+use crate::agent::loop_::history::{extract_facts_from_turns, TurnBuffer};
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
use crate::agent::research;
@@ -37,6 +38,8 @@ pub struct Agent {
skills: Vec,
skills_prompt_mode: crate::config::SkillsPromptInjectionMode,
auto_save: bool,
+ session_id: Option,
+ turn_buffer: TurnBuffer,
history: Vec,
classification_config: crate::config::QueryClassificationConfig,
available_hints: Vec,
@@ -60,6 +63,7 @@ pub struct AgentBuilder {
skills: Option>,
skills_prompt_mode: Option,
auto_save: Option,
+ session_id: Option,
classification_config: Option,
available_hints: Option>,
route_model_by_hint: Option>,
@@ -84,6 +88,7 @@ impl AgentBuilder {
skills: None,
skills_prompt_mode: None,
auto_save: None,
+ session_id: None,
classification_config: None,
available_hints: None,
route_model_by_hint: None,
@@ -169,6 +174,12 @@ impl AgentBuilder {
self
}
+ /// Set the session identifier for memory isolation across users/channels.
+ pub fn session_id(mut self, session_id: String) -> Self {
+ self.session_id = Some(session_id);
+ self
+ }
+
pub fn classification_config(
mut self,
classification_config: crate::config::QueryClassificationConfig,
@@ -229,6 +240,8 @@ impl AgentBuilder {
skills: self.skills.unwrap_or_default(),
skills_prompt_mode: self.skills_prompt_mode.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
+ session_id: self.session_id,
+ turn_buffer: TurnBuffer::new(),
history: Vec::new(),
classification_config: self.classification_config.unwrap_or_default(),
available_hints: self.available_hints.unwrap_or_default(),
@@ -243,6 +256,10 @@ impl Agent {
AgentBuilder::new()
}
+ pub fn tool_specs(&self) -> &[ToolSpec] {
+ &self.tool_specs
+ }
+
pub fn history(&self) -> &[ConversationMessage] {
&self.history
}
@@ -252,6 +269,10 @@ impl Agent {
}
pub fn from_config(config: &Config) -> Result {
+ if let Err(error) = crate::plugins::runtime::initialize_from_config(&config.plugins) {
+ tracing::warn!("plugin registry initialization skipped: {error}");
+ }
+
let observer: Arc =
Arc::from(observability::create_observer(&config.observability));
let runtime: Arc =
@@ -295,6 +316,36 @@ impl Agent {
config.api_key.as_deref(),
config,
);
+ let (tools, tool_filter_report) = tools::filter_primary_agent_tools(
+ tools,
+ &config.agent.allowed_tools,
+ &config.agent.denied_tools,
+ );
+ for unmatched in tool_filter_report.unmatched_allowed_tools {
+ tracing::debug!(
+ tool = %unmatched,
+ "agent.allowed_tools entry did not match any registered tool"
+ );
+ }
+ let has_agent_allowlist = config
+ .agent
+ .allowed_tools
+ .iter()
+ .any(|entry| !entry.trim().is_empty());
+ let has_agent_denylist = config
+ .agent
+ .denied_tools
+ .iter()
+ .any(|entry| !entry.trim().is_empty());
+ if has_agent_allowlist
+ && has_agent_denylist
+ && tool_filter_report.allowlist_match_count > 0
+ && tools.is_empty()
+ {
+ anyhow::bail!(
+ "agent.allowed_tools and agent.denied_tools removed all executable tools; update [agent] tool filters"
+ );
+ }
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
@@ -400,37 +451,38 @@ impl Agent {
async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
let start = Instant::now();
- let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
- match tool.execute(call.arguments.clone()).await {
- Ok(r) => {
- self.observer.record_event(&ObserverEvent::ToolCall {
- tool: call.name.clone(),
- duration: start.elapsed(),
- success: r.success,
- });
- if r.success {
- r.output
- } else {
- format!("Error: {}", r.error.unwrap_or(r.output))
+ let (result, success) =
+ if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
+ match tool.execute(call.arguments.clone()).await {
+ Ok(r) => {
+ self.observer.record_event(&ObserverEvent::ToolCall {
+ tool: call.name.clone(),
+ duration: start.elapsed(),
+ success: r.success,
+ });
+ if r.success {
+ (r.output, true)
+ } else {
+ (format!("Error: {}", r.error.unwrap_or(r.output)), false)
+ }
+ }
+ Err(e) => {
+ self.observer.record_event(&ObserverEvent::ToolCall {
+ tool: call.name.clone(),
+ duration: start.elapsed(),
+ success: false,
+ });
+ (format!("Error executing {}: {e}", call.name), false)
}
}
- Err(e) => {
- self.observer.record_event(&ObserverEvent::ToolCall {
- tool: call.name.clone(),
- duration: start.elapsed(),
- success: false,
- });
- format!("Error executing {}: {e}", call.name)
- }
- }
- } else {
- format!("Unknown tool: {}", call.name)
- };
+ } else {
+ (format!("Unknown tool: {}", call.name), false)
+ };
ToolExecutionResult {
name: call.name.clone(),
output: result,
- success: true,
+ success,
tool_call_id: call.tool_call_id.clone(),
}
}
@@ -482,12 +534,21 @@ impl Agent {
.push(ConversationMessage::Chat(ChatMessage::system(
system_prompt,
)));
+ } else if let Some(ConversationMessage::Chat(system_msg)) = self.history.first_mut() {
+ if system_msg.role == "system" {
+ crate::agent::prompt::refresh_prompt_datetime(&mut system_msg.content);
+ }
}
if self.auto_save {
let _ = self
.memory
- .store("user_msg", user_message, MemoryCategory::Conversation, None)
+ .store(
+ "user_msg",
+ user_message,
+ MemoryCategory::Conversation,
+ self.session_id.as_deref(),
+ )
.await;
}
@@ -604,12 +665,31 @@ impl Agent {
"assistant_resp",
&final_text,
MemoryCategory::Conversation,
- None,
+ self.session_id.as_deref(),
)
.await;
}
self.trim_history();
+ // ── Post-turn fact extraction ──────────────────────
+ if self.auto_save {
+ self.turn_buffer.push(user_message, &final_text);
+ if self.turn_buffer.should_extract() {
+ let turns = self.turn_buffer.drain_for_extraction();
+ let result = extract_facts_from_turns(
+ self.provider.as_ref(),
+ &self.model_name,
+ &turns,
+ self.memory.as_ref(),
+ self.session_id.as_deref(),
+ )
+ .await;
+ if result.stored > 0 || result.no_facts {
+ self.turn_buffer.mark_extract_success();
+ }
+ }
+ }
+
return Ok(final_text);
}
@@ -665,8 +745,44 @@ impl Agent {
)
}
+ /// Flush any remaining buffered turns for fact extraction.
+ /// Call this when the session/conversation ends to avoid losing
+ /// facts from short (< 5 turn) sessions.
+ ///
+ /// On failure the turns are restored so callers that keep the agent
+ /// alive can still fall back to compaction-based extraction.
+ pub async fn flush_turn_buffer(&mut self) {
+ if !self.auto_save || self.turn_buffer.is_empty() {
+ return;
+ }
+ let turns = self.turn_buffer.drain_for_extraction();
+ let result = extract_facts_from_turns(
+ self.provider.as_ref(),
+ &self.model_name,
+ &turns,
+ self.memory.as_ref(),
+ self.session_id.as_deref(),
+ )
+ .await;
+ if result.stored > 0 || result.no_facts {
+ self.turn_buffer.mark_extract_success();
+ } else {
+ // Restore turns so compaction fallback can still pick them up
+ // if the agent isn't dropped immediately.
+ tracing::warn!(
+ "Exit flush failed; restoring {} turn(s) to buffer",
+ turns.len()
+ );
+ for (u, a) in turns {
+ self.turn_buffer.push(&u, &a);
+ }
+ }
+ }
+
pub async fn run_single(&mut self, message: &str) -> Result {
- self.turn(message).await
+ let result = self.turn(message).await?;
+ self.flush_turn_buffer().await;
+ Ok(result)
}
pub async fn run_interactive(&mut self) -> Result<()> {
@@ -692,6 +808,7 @@ impl Agent {
}
listen_handle.abort();
+ self.flush_turn_buffer().await;
Ok(())
}
}
@@ -760,6 +877,7 @@ mod tests {
use async_trait::async_trait;
use parking_lot::Mutex;
use std::collections::HashMap;
+ use tempfile::TempDir;
struct MockProvider {
responses: Mutex>,
@@ -791,6 +909,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@@ -829,6 +949,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
});
}
Ok(guard.remove(0))
@@ -869,6 +991,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
}]),
});
@@ -910,6 +1034,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
},
crate::providers::ChatResponse {
text: Some("done".into()),
@@ -917,6 +1043,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
},
]),
});
@@ -959,6 +1087,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
}]),
seen_models: seen_models.clone(),
});
@@ -1003,4 +1133,118 @@ mod tests {
let seen = seen_models.lock();
assert_eq!(seen.as_slice(), &["hint:fast".to_string()]);
}
+
+ #[test]
+ fn from_config_loads_plugin_declared_tools() {
+ let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
+ let tmp = TempDir::new().expect("temp dir");
+ let plugin_dir = tmp.path().join("plugins");
+ std::fs::create_dir_all(&plugin_dir).expect("create plugin dir");
+ std::fs::create_dir_all(tmp.path().join("workspace")).expect("create workspace dir");
+
+ std::fs::write(
+ plugin_dir.join("agent_from_config.plugin.toml"),
+ r#"
+id = "agent-from-config"
+version = "1.0.0"
+module_path = "plugins/agent-from-config.wasm"
+wit_packages = ["zeroclaw:tools@1.0.0"]
+
+[[tools]]
+name = "__agent_from_config_plugin_tool"
+description = "plugin tool exposed for from_config tests"
+"#,
+ )
+ .expect("write plugin manifest");
+
+ let mut config = Config::default();
+ config.workspace_dir = tmp.path().join("workspace");
+ config.config_path = tmp.path().join("config.toml");
+ config.default_provider = Some("ollama".to_string());
+ config.memory.backend = "none".to_string();
+ config.plugins = crate::config::PluginsConfig {
+ enabled: true,
+ load_paths: vec![plugin_dir.to_string_lossy().to_string()],
+ ..crate::config::PluginsConfig::default()
+ };
+
+ let agent = Agent::from_config(&config).expect("agent from config should build");
+ assert!(agent
+ .tools
+ .iter()
+ .any(|tool| tool.name() == "__agent_from_config_plugin_tool"));
+ }
+
+ fn base_from_config_for_tool_filter_tests() -> Config {
+ let root = std::env::temp_dir().join(format!(
+ "zeroclaw_agent_tool_filter_{}",
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(root.join("workspace")).expect("create workspace dir");
+
+ let mut config = Config::default();
+ config.workspace_dir = root.join("workspace");
+ config.config_path = root.join("config.toml");
+ config.default_provider = Some("ollama".to_string());
+ config.memory.backend = "none".to_string();
+ config
+ }
+
+ #[test]
+ fn from_config_primary_allowlist_filters_tools() {
+ let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
+ let mut config = base_from_config_for_tool_filter_tests();
+ config.agent.allowed_tools = vec!["shell".to_string()];
+
+ let agent = Agent::from_config(&config).expect("agent should build");
+ let names: Vec<&str> = agent.tools.iter().map(|tool| tool.name()).collect();
+ assert_eq!(names, vec!["shell"]);
+ }
+
+ #[test]
+ fn from_config_empty_allowlist_preserves_default_toolset() {
+ let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
+ let config = base_from_config_for_tool_filter_tests();
+
+ let agent = Agent::from_config(&config).expect("agent should build");
+ let names: Vec<&str> = agent.tools.iter().map(|tool| tool.name()).collect();
+ assert!(names.contains(&"shell"));
+ assert!(names.contains(&"file_read"));
+ }
+
+ #[test]
+ fn from_config_primary_denylist_removes_tools() {
+ let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
+ let mut config = base_from_config_for_tool_filter_tests();
+ config.agent.denied_tools = vec!["shell".to_string()];
+
+ let agent = Agent::from_config(&config).expect("agent should build");
+ let names: Vec<&str> = agent.tools.iter().map(|tool| tool.name()).collect();
+ assert!(!names.contains(&"shell"));
+ }
+
+ #[test]
+ fn from_config_unmatched_allowlist_entry_is_graceful() {
+ let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
+ let mut config = base_from_config_for_tool_filter_tests();
+ config.agent.allowed_tools = vec!["missing_tool".to_string()];
+
+ let agent = Agent::from_config(&config).expect("agent should build with empty toolset");
+ assert!(agent.tools.is_empty());
+ }
+
+ #[test]
+ fn from_config_conflicting_allow_and_deny_fails_fast() {
+ let _guard = crate::test_locks::PLUGIN_RUNTIME_LOCK.lock();
+ let mut config = base_from_config_for_tool_filter_tests();
+ config.agent.allowed_tools = vec!["shell".to_string()];
+ config.agent.denied_tools = vec!["shell".to_string()];
+
+ let err = Agent::from_config(&config)
+ .err()
+ .expect("expected filter conflict");
+ assert!(err
+ .to_string()
+ .contains("agent.allowed_tools and agent.denied_tools removed all executable tools"));
+ }
}
diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs
index 2dda0b93a..b13591f1d 100644
--- a/src/agent/dispatcher.rs
+++ b/src/agent/dispatcher.rs
@@ -264,6 +264,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
};
let dispatcher = XmlToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
@@ -283,6 +285,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
};
let dispatcher = NativeToolDispatcher;
let (_, calls) = dispatcher.parse_response(&response);
diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs
index 7e4d033ca..b46f589be 100644
--- a/src/agent/loop_.rs
+++ b/src/agent/loop_.rs
@@ -1,13 +1,16 @@
use crate::approval::{ApprovalManager, ApprovalRequest, ApprovalResponse};
-use crate::config::Config;
+use crate::config::schema::{CostEnforcementMode, ModelPricing};
+use crate::config::{Config, ProgressMode};
+use crate::cost::{BudgetCheck, CostTracker, UsagePeriod};
use crate::memory::{self, Memory, MemoryCategory};
use crate::multimodal;
use crate::observability::{self, runtime_trace, Observer, ObserverEvent};
use crate::providers::{
- self, ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall,
+ self, ChatMessage, ChatRequest, NormalizedStopReason, Provider, ProviderCapabilityError,
+ ToolCall,
};
use crate::runtime;
-use crate::security::SecurityPolicy;
+use crate::security::{CanaryGuard, SecurityPolicy};
use crate::tools::{self, Tool};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
@@ -19,9 +22,11 @@ use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{CompletionType, Config as RlConfig, Context, Editor, Helper};
use std::borrow::Cow;
-use std::collections::{BTreeSet, HashSet};
+use std::collections::{BTreeSet, HashMap, HashSet};
use std::fmt::Write;
+use std::future::Future;
use std::io::Write as _;
+use std::path::Path;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
@@ -30,7 +35,7 @@ use uuid::Uuid;
mod context;
pub(crate) mod detection;
mod execution;
-mod history;
+pub(crate) mod history;
mod parsing;
use context::{build_context, build_hardware_context};
@@ -41,7 +46,7 @@ use execution::{
};
#[cfg(test)]
use history::{apply_compaction_summary, build_compaction_transcript};
-use history::{auto_compact_history, trim_history};
+use history::{auto_compact_history, extract_facts_from_turns, trim_history, TurnBuffer};
#[allow(unused_imports)]
use parsing::{
default_param_for_tool, detect_tool_call_parse_issue, extract_json_values, map_tool_name_alias,
@@ -57,10 +62,72 @@ const STREAM_CHUNK_MIN_CHARS: usize = 80;
/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero.
const DEFAULT_MAX_TOOL_ITERATIONS: usize = 20;
+/// Maximum continuation retries when a provider reports max-token truncation.
+const MAX_TOKENS_CONTINUATION_MAX_ATTEMPTS: usize = 3;
+/// Absolute safety cap for merged continuation output.
+const MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS: usize = 120_000;
+/// Deterministic continuation instruction appended as a user message.
+const MAX_TOKENS_CONTINUATION_PROMPT: &str = "Previous response was truncated by token limit.\nContinue exactly from where you left off.\nIf you intended a tool call, emit one complete tool call payload only.\nDo not repeat already-sent text.";
+/// Notice appended when continuation budget is exhausted before completion.
+const MAX_TOKENS_CONTINUATION_NOTICE: &str =
+ "\n\n[Response may be truncated due to continuation limits. Reply \"continue\" to resume.]";
+
+/// Returned when canary token exfiltration is detected in model output.
+const CANARY_EXFILTRATION_BLOCK_MESSAGE: &str =
+ "I blocked that response because it attempted to reveal protected internal context.";
+
/// Minimum user-message length (in chars) for auto-save to memory.
/// Matches the channel-side constant in `channels/mod.rs`.
const AUTOSAVE_MIN_MESSAGE_CHARS: usize = 20;
+fn filter_primary_agent_tools_or_fail(
+ config: &Config,
+ tools_registry: Vec>,
+) -> Result>> {
+ let (filtered_tools, report) = tools::filter_primary_agent_tools(
+ tools_registry,
+ &config.agent.allowed_tools,
+ &config.agent.denied_tools,
+ );
+
+ for unmatched in report.unmatched_allowed_tools {
+ tracing::debug!(
+ tool = %unmatched,
+ "agent.allowed_tools entry did not match any registered tool"
+ );
+ }
+
+ let has_agent_allowlist = config
+ .agent
+ .allowed_tools
+ .iter()
+ .any(|entry| !entry.trim().is_empty());
+ let has_agent_denylist = config
+ .agent
+ .denied_tools
+ .iter()
+ .any(|entry| !entry.trim().is_empty());
+ if has_agent_allowlist
+ && has_agent_denylist
+ && report.allowlist_match_count > 0
+ && filtered_tools.is_empty()
+ {
+ anyhow::bail!(
+ "agent.allowed_tools and agent.denied_tools removed all executable tools; update [agent] tool filters"
+ );
+ }
+
+ Ok(filtered_tools)
+}
+
+fn retain_visible_tool_descriptions<'a>(
+ tool_descs: &mut Vec<(&'a str, &'a str)>,
+ tools_registry: &[Box],
+) {
+ let visible_tools: HashSet<&str> = tools_registry.iter().map(|tool| tool.name()).collect();
+ tool_descs.retain(|(name, _)| visible_tools.contains(*name));
+}
+
fn should_treat_provider_as_vision_capable(provider_name: &str, provider: &dyn Provider) -> bool {
if provider.supports_vision() {
return true;
@@ -254,11 +321,21 @@ pub(crate) const DRAFT_CLEAR_SENTINEL: &str = "\x00CLEAR\x00";
/// Channel layers can suppress these messages by default and only expose them
/// when the user explicitly asks for command/tool execution details.
pub(crate) const DRAFT_PROGRESS_SENTINEL: &str = "\x00PROGRESS\x00";
+/// Sentinel prefix for full in-place progress blocks.
+pub(crate) const DRAFT_PROGRESS_BLOCK_SENTINEL: &str = "\x00PROGRESS_BLOCK\x00";
+/// Progress-section marker inserted into accumulated streaming drafts.
+pub(crate) const DRAFT_PROGRESS_SECTION_START: &str = "\n\n";
+/// Progress-section marker inserted into accumulated streaming drafts.
+pub(crate) const DRAFT_PROGRESS_SECTION_END: &str = "\n\n";
tokio::task_local! {
static TOOL_LOOP_REPLY_TARGET: Option;
}
+tokio::task_local! {
+ static TOOL_LOOP_CANARY_TOKENS_ENABLED: bool;
+}
+
const AUTO_CRON_DELIVERY_CHANNELS: &[&str] = &[
"telegram",
"discord",
@@ -290,6 +367,8 @@ tokio::task_local! {
static TOOL_LOOP_NON_CLI_APPROVAL_CONTEXT: Option;
static LOOP_DETECTION_CONFIG: LoopDetectionConfig;
static SAFETY_HEARTBEAT_CONFIG: Option;
+ static TOOL_LOOP_PROGRESS_MODE: ProgressMode;
+ static TOOL_LOOP_COST_ENFORCEMENT_CONTEXT: Option;
}
/// Configuration for periodic safety-constraint re-injection (heartbeat).
@@ -301,15 +380,226 @@ pub(crate) struct SafetyHeartbeatConfig {
pub interval: usize,
}
+#[derive(Clone)]
+pub(crate) struct CostEnforcementContext {
+ tracker: Arc,
+ prices: HashMap,
+ mode: CostEnforcementMode,
+ route_down_model: Option,
+ reserve_percent: u8,
+}
+
+pub(crate) fn create_cost_enforcement_context(
+ cost_config: &crate::config::CostConfig,
+ workspace_dir: &Path,
+) -> Option {
+ if !cost_config.enabled {
+ return None;
+ }
+ let tracker = match CostTracker::new(cost_config.clone(), workspace_dir) {
+ Ok(tracker) => Arc::new(tracker),
+ Err(error) => {
+ tracing::warn!("Cost budget preflight disabled: failed to initialize tracker: {error}");
+ return None;
+ }
+ };
+ let route_down_model = cost_config
+ .enforcement
+ .route_down_model
+ .clone()
+ .map(|value| value.trim().to_string())
+ .filter(|value| !value.is_empty());
+ Some(CostEnforcementContext {
+ tracker,
+ prices: cost_config.prices.clone(),
+ mode: cost_config.enforcement.mode,
+ route_down_model,
+ reserve_percent: cost_config.enforcement.reserve_percent.min(100),
+ })
+}
+
+pub(crate) async fn scope_cost_enforcement_context(
+ context: Option,
+ future: F,
+) -> F::Output
+where
+ F: Future,
+{
+ TOOL_LOOP_COST_ENFORCEMENT_CONTEXT
+ .scope(context, future)
+ .await
+}
+
fn should_inject_safety_heartbeat(counter: usize, interval: usize) -> bool {
interval > 0 && counter > 0 && counter % interval == 0
}
+fn should_emit_verbose_progress(mode: ProgressMode) -> bool {
+ mode == ProgressMode::Verbose
+}
+
+fn should_emit_tool_progress(mode: ProgressMode) -> bool {
+ mode != ProgressMode::Off
+}
+
+fn estimate_prompt_tokens(
+ messages: &[ChatMessage],
+ tools: Option<&[crate::tools::ToolSpec]>,
+) -> u64 {
+ let message_chars: usize = messages
+ .iter()
+ .map(|msg| {
+ msg.role
+ .len()
+ .saturating_add(msg.content.chars().count())
+ .saturating_add(16)
+ })
+ .sum();
+ let tool_chars: usize = tools
+ .map(|specs| {
+ specs
+ .iter()
+ .map(|spec| serde_json::to_string(spec).map_or(0, |value| value.chars().count()))
+ .sum()
+ })
+ .unwrap_or(0);
+ let total_chars = message_chars.saturating_add(tool_chars);
+ let char_estimate = (total_chars as f64 / 4.0).ceil() as u64;
+ let framing_overhead = (messages.len() as u64).saturating_mul(6).saturating_add(64);
+ char_estimate.saturating_add(framing_overhead)
+}
+
+fn lookup_model_pricing(
+ prices: &HashMap,
+ provider: &str,
+ model: &str,
+) -> (f64, f64) {
+ let full_name = format!("{provider}/{model}");
+ if let Some(pricing) = prices.get(&full_name) {
+ return (pricing.input, pricing.output);
+ }
+ if let Some(pricing) = prices.get(model) {
+ return (pricing.input, pricing.output);
+ }
+ for (key, pricing) in prices {
+ let key_model = key.split('/').next_back().unwrap_or(key);
+ if model.starts_with(key_model) || key_model.starts_with(model) {
+ return (pricing.input, pricing.output);
+ }
+ let normalized_model = model.replace('-', ".");
+ let normalized_key = key_model.replace('-', ".");
+ if normalized_model.contains(&normalized_key) || normalized_key.contains(&normalized_model)
+ {
+ return (pricing.input, pricing.output);
+ }
+ }
+ (3.0, 15.0)
+}
+
+fn estimate_request_cost_usd(
+ context: &CostEnforcementContext,
+ provider: &str,
+ model: &str,
+ messages: &[ChatMessage],
+ tools: Option<&[crate::tools::ToolSpec]>,
+) -> f64 {
+ let reserve_multiplier = 1.0 + (f64::from(context.reserve_percent) / 100.0);
+ let input_tokens = estimate_prompt_tokens(messages, tools);
+ let output_tokens = (input_tokens / 4).max(256);
+ let input_tokens = ((input_tokens as f64) * reserve_multiplier).ceil() as u64;
+ let output_tokens = ((output_tokens as f64) * reserve_multiplier).ceil() as u64;
+
+ let (input_price, output_price) = lookup_model_pricing(&context.prices, provider, model);
+ let input_cost = (input_tokens as f64 / 1_000_000.0) * input_price.max(0.0);
+ let output_cost = (output_tokens as f64 / 1_000_000.0) * output_price.max(0.0);
+ input_cost + output_cost
+}
+
+fn usage_period_label(period: UsagePeriod) -> &'static str {
+ match period {
+ UsagePeriod::Session => "session",
+ UsagePeriod::Day => "daily",
+ UsagePeriod::Month => "monthly",
+ }
+}
+
+fn budget_exceeded_message(
+ model: &str,
+ estimated_cost_usd: f64,
+ current_usd: f64,
+ limit_usd: f64,
+ period: UsagePeriod,
+) -> String {
+ format!(
+ "Budget enforcement blocked request for model '{model}': projected cost (+${estimated_cost_usd:.4}) exceeds {period_label} limit (${limit_usd:.2}, current ${current_usd:.2}).",
+ period_label = usage_period_label(period)
+ )
+}
+
+#[derive(Debug, Clone)]
+struct ProgressEntry {
+ name: String,
+ hint: String,
+ completion: Option<(bool, u64)>,
+}
+
+#[derive(Debug, Default)]
+struct ProgressTracker {
+ entries: Vec,
+}
+
+impl ProgressTracker {
+ fn add(&mut self, tool_name: &str, hint: &str) -> usize {
+ let idx = self.entries.len();
+ self.entries.push(ProgressEntry {
+ name: tool_name.to_string(),
+ hint: hint.to_string(),
+ completion: None,
+ });
+ idx
+ }
+
+ fn complete(&mut self, idx: usize, success: bool, secs: u64) {
+ if let Some(entry) = self.entries.get_mut(idx) {
+ entry.completion = Some((success, secs));
+ }
+ }
+
+ fn render_delta(&self) -> String {
+ let mut out = String::from(DRAFT_PROGRESS_BLOCK_SENTINEL);
+ for entry in &self.entries {
+ match entry.completion {
+ None => {
+ let _ = write!(out, "\u{23f3} {}", entry.name);
+ if !entry.hint.is_empty() {
+ let _ = write!(out, ": {}", entry.hint);
+ }
+ out.push('\n');
+ }
+ Some((true, secs)) => {
+ let _ = writeln!(out, "\u{2705} {} ({secs}s)", entry.name);
+ }
+ Some((false, secs)) => {
+ let _ = writeln!(out, "\u{274c} {} ({secs}s)", entry.name);
+ }
+ }
+ }
+ out
+ }
+}
/// Extract a short hint from tool call arguments for progress display.
fn truncate_tool_args_for_progress(name: &str, args: &serde_json::Value, max_len: usize) -> String {
let hint = match name {
"shell" => args.get("command").and_then(|v| v.as_str()),
"file_read" | "file_write" => args.get("path").and_then(|v| v.as_str()),
+ "composio_execute" => args.get("action_name").and_then(|v| v.as_str()),
+ "memory_recall" => args.get("query").and_then(|v| v.as_str()),
+ "memory_store" => args.get("key").and_then(|v| v.as_str()),
+ "web_search" => args.get("query").and_then(|v| v.as_str()),
+ "http_request" => args.get("url").and_then(|v| v.as_str()),
+ "browser_navigate" | "browser_screenshot" | "browser_click" | "browser_type" => {
+ args.get("url").and_then(|v| v.as_str())
+ }
_ => args
.get("action")
.and_then(|v| v.as_str())
@@ -336,11 +626,67 @@ fn looks_like_deferred_action_without_tool_call(text: &str) -> bool {
&& CJK_DEFERRED_ACTION_VERB_REGEX.is_match(trimmed)
}
+fn merge_continuation_text(existing: &str, next: &str) -> String {
+ if next.is_empty() {
+ return existing.to_string();
+ }
+ if existing.is_empty() {
+ return next.to_string();
+ }
+ if existing.ends_with(next) {
+ return existing.to_string();
+ }
+ if next.starts_with(existing) {
+ return next.to_string();
+ }
+
+ let mut prefix_ends: Vec = next.char_indices().map(|(idx, _)| idx).collect();
+ prefix_ends.push(next.len());
+ for prefix_end in prefix_ends.into_iter().rev() {
+ if prefix_end == 0 || prefix_end > existing.len() {
+ continue;
+ }
+ if existing.ends_with(&next[..prefix_end]) {
+ return format!("{existing}{}", &next[prefix_end..]);
+ }
+ }
+
+ format!("{existing}{next}")
+}
+
+fn add_optional_u64(lhs: Option, rhs: Option) -> Option {
+ match (lhs, rhs) {
+ (Some(left), Some(right)) => Some(left.saturating_add(right)),
+ (Some(left), None) => Some(left),
+ (None, Some(right)) => Some(right),
+ (None, None) => None,
+ }
+}
+
+fn stop_reason_name(reason: &NormalizedStopReason) -> &'static str {
+ match reason {
+ NormalizedStopReason::EndTurn => "end_turn",
+ NormalizedStopReason::ToolCall => "tool_call",
+ NormalizedStopReason::MaxTokens => "max_tokens",
+ NormalizedStopReason::ContextWindowExceeded => "context_window_exceeded",
+ NormalizedStopReason::SafetyBlocked => "safety_blocked",
+ NormalizedStopReason::Cancelled => "cancelled",
+ NormalizedStopReason::Unknown(_) => "unknown",
+ }
+}
+
+fn is_legacy_cron_model_fallback(model: &str) -> bool {
+ let normalized = model.trim().to_ascii_lowercase();
+ matches!(normalized.as_str(), "gpt-4o-mini" | "openai/gpt-4o-mini")
+}
+
fn maybe_inject_cron_add_delivery(
tool_name: &str,
tool_args: &mut serde_json::Value,
channel_name: &str,
reply_target: Option<&str>,
+ provider_name: &str,
+ active_model: &str,
) {
if tool_name != "cron_add"
|| !AUTO_CRON_DELIVERY_CHANNELS
@@ -409,6 +755,44 @@ fn maybe_inject_cron_add_delivery(
serde_json::Value::String(reply_target.to_string()),
);
}
+
+ let active_model = active_model.trim();
+ if active_model.is_empty() {
+ return;
+ }
+
+ let model_missing = args_obj
+ .get("model")
+ .and_then(serde_json::Value::as_str)
+ .is_none_or(|value| value.trim().is_empty());
+ if model_missing {
+ args_obj.insert(
+ "model".to_string(),
+ serde_json::Value::String(active_model.to_string()),
+ );
+ return;
+ }
+
+ let is_custom_provider = provider_name
+ .trim()
+ .to_ascii_lowercase()
+ .starts_with("custom:");
+ if !is_custom_provider {
+ return;
+ }
+
+ let should_replace_model = args_obj
+ .get("model")
+ .and_then(serde_json::Value::as_str)
+ .is_some_and(|value| {
+ is_legacy_cron_model_fallback(value) && !value.trim().eq_ignore_ascii_case(active_model)
+ });
+ if should_replace_model {
+ args_obj.insert(
+ "model".to_string(),
+ serde_json::Value::String(active_model.to_string()),
+ );
+ }
}
async fn await_non_cli_approval_decision(
@@ -612,25 +996,29 @@ pub(crate) async fn agent_turn(
multimodal_config: &crate::config::MultimodalConfig,
max_tool_iterations: usize,
) -> Result {
- run_tool_call_loop(
- provider,
- history,
- tools_registry,
- observer,
- provider_name,
- model,
- temperature,
- silent,
- None,
- "channel",
- multimodal_config,
- max_tool_iterations,
- None,
- None,
- None,
- &[],
- )
- .await
+ TOOL_LOOP_CANARY_TOKENS_ENABLED
+ .scope(
+ false,
+ run_tool_call_loop(
+ provider,
+ history,
+ tools_registry,
+ observer,
+ provider_name,
+ model,
+ temperature,
+ silent,
+ None,
+ "channel",
+ multimodal_config,
+ max_tool_iterations,
+ None,
+ None,
+ None,
+ &[],
+ ),
+ )
+ .await
}
/// Run the tool loop with channel reply_target context, used by channel runtimes
@@ -654,27 +1042,34 @@ pub(crate) async fn run_tool_call_loop_with_reply_target(
on_delta: Option>,
hooks: Option<&crate::hooks::HookRunner>,
excluded_tools: &[String],
+ progress_mode: ProgressMode,
) -> Result {
- TOOL_LOOP_REPLY_TARGET
+ TOOL_LOOP_PROGRESS_MODE
.scope(
- reply_target.map(str::to_string),
- 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,
+ progress_mode,
+ TOOL_LOOP_CANARY_TOKENS_ENABLED.scope(
+ false,
+ TOOL_LOOP_REPLY_TARGET.scope(
+ reply_target.map(str::to_string),
+ 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,
+ ),
+ ),
),
)
.await
@@ -700,36 +1095,44 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
on_delta: Option>,
hooks: Option<&crate::hooks::HookRunner>,
excluded_tools: &[String],
+ progress_mode: ProgressMode,
safety_heartbeat: Option,
+ canary_tokens_enabled: bool,
) -> Result {
let reply_target = non_cli_approval_context
.as_ref()
.map(|ctx| ctx.reply_target.clone());
- SAFETY_HEARTBEAT_CONFIG
+ TOOL_LOOP_PROGRESS_MODE
.scope(
- 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,
+ progress_mode,
+ SAFETY_HEARTBEAT_CONFIG.scope(
+ safety_heartbeat,
+ TOOL_LOOP_CANARY_TOKENS_ENABLED.scope(
+ canary_tokens_enabled,
+ 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,
+ ),
+ ),
),
),
),
@@ -752,7 +1155,7 @@ pub(crate) async fn run_tool_call_loop_with_non_cli_approval_context(
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
#[allow(clippy::too_many_arguments)]
-pub(crate) async fn run_tool_call_loop(
+pub async fn run_tool_call_loop(
provider: &dyn Provider,
history: &mut Vec,
tools_registry: &[Box],
@@ -809,6 +1212,32 @@ pub(crate) async fn run_tool_call_loop(
.try_with(Clone::clone)
.ok()
.flatten();
+ let progress_mode = TOOL_LOOP_PROGRESS_MODE
+ .try_with(|mode| *mode)
+ .unwrap_or(ProgressMode::Verbose);
+ let cost_enforcement_context = TOOL_LOOP_COST_ENFORCEMENT_CONTEXT
+ .try_with(Clone::clone)
+ .ok()
+ .flatten();
+ let mut progress_tracker = ProgressTracker::default();
+ let mut active_model = model.to_string();
+ let canary_guard = CanaryGuard::new(
+ TOOL_LOOP_CANARY_TOKENS_ENABLED
+ .try_with(|enabled| *enabled)
+ .unwrap_or(false),
+ );
+ let mut turn_canary_token: Option = None;
+ if let Some(system_message) = history.first_mut() {
+ if system_message.role == "system" {
+ let (updated_prompt, token) = canary_guard.inject_turn_token(&system_message.content);
+ system_message.content = updated_prompt;
+ turn_canary_token = token;
+ }
+ }
+ let redact_trace_text = |text: &str| -> String {
+ let scrubbed = scrub_credentials(text);
+ canary_guard.redact_token_from_text(&scrubbed, turn_canary_token.as_deref())
+ };
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 {
@@ -816,7 +1245,7 @@ pub(crate) async fn run_tool_call_loop(
"approval_bypass_one_time_all_tools_consumed",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(true),
Some("consumed one-time non-cli allow-all approval token"),
@@ -846,8 +1275,12 @@ pub(crate) async fn run_tool_call_loop(
.into());
}
- let prepared_messages =
- multimodal::prepare_messages_for_provider(history, multimodal_config).await?;
+ let prepared_messages = multimodal::prepare_messages_for_provider_with_provider_hint(
+ history,
+ multimodal_config,
+ Some(provider_name),
+ )
+ .await?;
let mut request_messages = prepared_messages.messages.clone();
if let Some(prompt) = missing_tool_call_retry_prompt.take() {
request_messages.push(ChatMessage::user(prompt));
@@ -868,27 +1301,195 @@ pub(crate) async fn run_tool_call_loop(
request_messages.push(ChatMessage::user(reminder));
}
}
+ // Unified path via Provider::chat so provider-specific native tool logic
+ // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored.
+ let request_tools = if use_native_tools {
+ Some(tool_specs.as_slice())
+ } else {
+ None
+ };
// ── Progress: LLM thinking ────────────────────────────
- if let Some(ref tx) = on_delta {
- let phase = if iteration == 0 {
- "\u{1f914} Thinking...\n".to_string()
- } else {
- format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
+ if should_emit_verbose_progress(progress_mode) {
+ if let Some(ref tx) = on_delta {
+ let phase = if iteration == 0 {
+ "\u{1f914} Thinking...\n".to_string()
+ } else {
+ format!("\u{1f914} Thinking (round {})...\n", iteration + 1)
+ };
+ let _ = tx.send(format!("{DRAFT_PROGRESS_SENTINEL}{phase}")).await;
+ }
+ }
+
+ if let Some(cost_ctx) = cost_enforcement_context.as_ref() {
+ let mut estimated_cost_usd = estimate_request_cost_usd(
+ cost_ctx,
+ provider_name,
+ active_model.as_str(),
+ &request_messages,
+ request_tools,
+ );
+
+ let mut budget_check = match cost_ctx.tracker.check_budget(estimated_cost_usd) {
+ Ok(check) => Some(check),
+ Err(error) => {
+ tracing::warn!("Cost preflight check failed: {error}");
+ None
+ }
};
- let _ = tx.send(format!("{DRAFT_PROGRESS_SENTINEL}{phase}")).await;
+
+ if matches!(cost_ctx.mode, CostEnforcementMode::RouteDown)
+ && matches!(budget_check, Some(BudgetCheck::Exceeded { .. }))
+ {
+ if let Some(route_down_model) = cost_ctx
+ .route_down_model
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ {
+ if route_down_model != active_model {
+ let previous_model = active_model.clone();
+ active_model = route_down_model.to_string();
+ estimated_cost_usd = estimate_request_cost_usd(
+ cost_ctx,
+ provider_name,
+ active_model.as_str(),
+ &request_messages,
+ request_tools,
+ );
+ budget_check = match cost_ctx.tracker.check_budget(estimated_cost_usd) {
+ Ok(check) => Some(check),
+ Err(error) => {
+ tracing::warn!(
+ "Cost preflight check failed after route-down: {error}"
+ );
+ None
+ }
+ };
+ runtime_trace::record_event(
+ "cost_budget_route_down",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(true),
+ Some("budget exceeded on primary model; route-down candidate applied"),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "from_model": previous_model,
+ "to_model": active_model,
+ "estimated_cost_usd": estimated_cost_usd,
+ }),
+ );
+ }
+ }
+ }
+
+ if let Some(check) = budget_check {
+ match check {
+ BudgetCheck::Allowed => {}
+ BudgetCheck::Warning {
+ current_usd,
+ limit_usd,
+ period,
+ } => {
+ tracing::warn!(
+ model = active_model.as_str(),
+ period = usage_period_label(period),
+ current_usd,
+ limit_usd,
+ estimated_cost_usd,
+ "Cost budget warning threshold reached"
+ );
+ runtime_trace::record_event(
+ "cost_budget_warning",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(true),
+ Some("budget warning threshold reached"),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "period": usage_period_label(period),
+ "current_usd": current_usd,
+ "limit_usd": limit_usd,
+ "estimated_cost_usd": estimated_cost_usd,
+ }),
+ );
+ }
+ BudgetCheck::Exceeded {
+ current_usd,
+ limit_usd,
+ period,
+ } => match cost_ctx.mode {
+ CostEnforcementMode::Warn => {
+ tracing::warn!(
+ model = active_model.as_str(),
+ period = usage_period_label(period),
+ current_usd,
+ limit_usd,
+ estimated_cost_usd,
+ "Cost budget exceeded (warn mode): continuing request"
+ );
+ runtime_trace::record_event(
+ "cost_budget_exceeded_warn_mode",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(true),
+ Some("budget exceeded but proceeding due to warn mode"),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "period": usage_period_label(period),
+ "current_usd": current_usd,
+ "limit_usd": limit_usd,
+ "estimated_cost_usd": estimated_cost_usd,
+ }),
+ );
+ }
+ CostEnforcementMode::RouteDown | CostEnforcementMode::Block => {
+ let message = budget_exceeded_message(
+ active_model.as_str(),
+ estimated_cost_usd,
+ current_usd,
+ limit_usd,
+ period,
+ );
+ runtime_trace::record_event(
+ "cost_budget_blocked",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(false),
+ Some(&message),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "period": usage_period_label(period),
+ "current_usd": current_usd,
+ "limit_usd": limit_usd,
+ "estimated_cost_usd": estimated_cost_usd,
+ }),
+ );
+ return Err(anyhow::anyhow!(message));
+ }
+ },
+ }
+ }
}
observer.record_event(&ObserverEvent::LlmRequest {
provider: provider_name.to_string(),
- model: model.to_string(),
+ model: active_model.clone(),
messages_count: history.len(),
});
runtime_trace::record_event(
"llm_request",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
None,
None,
@@ -902,23 +1503,15 @@ pub(crate) async fn run_tool_call_loop(
// Fire void hook before LLM call
if let Some(hooks) = hooks {
- hooks.fire_llm_input(history, model).await;
+ hooks.fire_llm_input(history, active_model.as_str()).await;
}
- // Unified path via Provider::chat so provider-specific native tool logic
- // (OpenAI/Anthropic/OpenRouter/compatible adapters) is honored.
- let request_tools = if use_native_tools {
- Some(tool_specs.as_slice())
- } else {
- None
- };
-
let chat_future = provider.chat(
ChatRequest {
messages: &request_messages,
tools: request_tools,
},
- model,
+ active_model.as_str(),
temperature,
);
@@ -940,15 +1533,186 @@ pub(crate) async fn run_tool_call_loop(
parse_issue_detected,
) = match chat_result {
Ok(resp) => {
- let (resp_input_tokens, resp_output_tokens) = resp
+ let mut response_text = resp.text_or_empty().to_string();
+ let mut native_calls = resp.tool_calls;
+ let mut reasoning_content = resp.reasoning_content.clone();
+ let mut stop_reason = resp.stop_reason.clone();
+ let mut raw_stop_reason = resp.raw_stop_reason.clone();
+ let (mut resp_input_tokens, mut resp_output_tokens) = resp
.usage
.as_ref()
.map(|u| (u.input_tokens, u.output_tokens))
.unwrap_or((None, None));
+ if let Some(reason) = stop_reason.as_ref() {
+ runtime_trace::record_event(
+ "stop_reason_observed",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(true),
+ None,
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "normalized_reason": stop_reason_name(reason),
+ "raw_reason": raw_stop_reason.clone(),
+ }),
+ );
+ }
+
+ let mut continuation_attempts = 0usize;
+ let mut continuation_termination_reason: Option<&'static str> = None;
+ let mut continuation_error: Option = None;
+ let mut output_chars = response_text.chars().count();
+
+ while matches!(stop_reason, Some(NormalizedStopReason::MaxTokens))
+ && native_calls.is_empty()
+ && continuation_attempts < MAX_TOKENS_CONTINUATION_MAX_ATTEMPTS
+ && output_chars < MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS
+ {
+ continuation_attempts += 1;
+ runtime_trace::record_event(
+ "continuation_attempt",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(true),
+ None,
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "attempt": continuation_attempts,
+ "output_chars": output_chars,
+ "max_output_chars": MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS,
+ }),
+ );
+
+ let mut continuation_messages = request_messages.clone();
+ continuation_messages.push(ChatMessage::assistant(response_text.clone()));
+ continuation_messages.push(ChatMessage::user(
+ MAX_TOKENS_CONTINUATION_PROMPT.to_string(),
+ ));
+
+ let continuation_future = provider.chat(
+ ChatRequest {
+ messages: &continuation_messages,
+ tools: request_tools,
+ },
+ active_model.as_str(),
+ temperature,
+ );
+ let continuation_result = if let Some(token) = cancellation_token.as_ref() {
+ tokio::select! {
+ () = token.cancelled() => return Err(ToolLoopCancelled.into()),
+ result = continuation_future => result,
+ }
+ } else {
+ continuation_future.await
+ };
+
+ let continuation_resp = match continuation_result {
+ Ok(response) => response,
+ Err(error) => {
+ continuation_termination_reason = Some("provider_error");
+ continuation_error =
+ Some(crate::providers::sanitize_api_error(&error.to_string()));
+ break;
+ }
+ };
+
+ if let Some(usage) = continuation_resp.usage.as_ref() {
+ resp_input_tokens = add_optional_u64(resp_input_tokens, usage.input_tokens);
+ resp_output_tokens =
+ add_optional_u64(resp_output_tokens, usage.output_tokens);
+ }
+
+ let next_text = continuation_resp.text_or_empty().to_string();
+ let merged_text = merge_continuation_text(&response_text, &next_text);
+ let merged_chars = merged_text.chars().count();
+ if merged_chars > MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS {
+ response_text = merged_text
+ .chars()
+ .take(MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS)
+ .collect();
+ output_chars = MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS;
+ stop_reason = Some(NormalizedStopReason::MaxTokens);
+ continuation_termination_reason = Some("output_cap");
+ break;
+ }
+ response_text = merged_text;
+ output_chars = merged_chars;
+
+ if continuation_resp.reasoning_content.is_some() {
+ reasoning_content = continuation_resp.reasoning_content.clone();
+ }
+ if !continuation_resp.tool_calls.is_empty() {
+ native_calls = continuation_resp.tool_calls;
+ }
+ stop_reason = continuation_resp.stop_reason;
+ raw_stop_reason = continuation_resp.raw_stop_reason;
+
+ if let Some(reason) = stop_reason.as_ref() {
+ runtime_trace::record_event(
+ "stop_reason_observed",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(true),
+ None,
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "continuation_attempt": continuation_attempts,
+ "normalized_reason": stop_reason_name(reason),
+ "raw_reason": raw_stop_reason.clone(),
+ }),
+ );
+ }
+ }
+
+ if continuation_attempts > 0 && continuation_termination_reason.is_none() {
+ continuation_termination_reason =
+ if matches!(stop_reason, Some(NormalizedStopReason::MaxTokens)) {
+ if output_chars >= MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS {
+ Some("output_cap")
+ } else {
+ Some("retry_limit")
+ }
+ } else {
+ Some("completed")
+ };
+ }
+
+ if let Some(terminal_reason) = continuation_termination_reason {
+ runtime_trace::record_event(
+ "continuation_terminated",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(terminal_reason == "completed"),
+ continuation_error.as_deref(),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "attempts": continuation_attempts,
+ "terminal_reason": terminal_reason,
+ "output_chars": output_chars,
+ }),
+ );
+ }
+
+ if continuation_attempts > 0
+ && matches!(stop_reason, Some(NormalizedStopReason::MaxTokens))
+ && native_calls.is_empty()
+ && !response_text.ends_with(MAX_TOKENS_CONTINUATION_NOTICE)
+ {
+ response_text.push_str(MAX_TOKENS_CONTINUATION_NOTICE);
+ }
+
observer.record_event(&ObserverEvent::LlmResponse {
provider: provider_name.to_string(),
- model: model.to_string(),
+ model: active_model.clone(),
duration: llm_started_at.elapsed(),
success: true,
error_message: None,
@@ -956,15 +1720,21 @@ pub(crate) async fn run_tool_call_loop(
output_tokens: resp_output_tokens,
});
- let response_text = resp.text_or_empty().to_string();
// First try native structured tool calls (OpenAI-format).
// Fall back to text-based parsing (XML tags, markdown blocks,
// GLM format) only if the provider returned no native calls —
// this ensures we support both native and prompt-guided models.
- let mut calls = parse_structured_tool_calls(&resp.tool_calls);
+ let structured_parse = parse_structured_tool_calls(&native_calls);
+ let invalid_native_tool_json_count = structured_parse.invalid_json_arguments;
+ let mut calls = structured_parse.calls;
+ if invalid_native_tool_json_count > 0 {
+ // Safety policy: when native tool-call args are partially truncated
+ // or malformed, do not execute any parsed subset in this turn.
+ calls.clear();
+ }
let mut parsed_text = String::new();
- if calls.is_empty() {
+ if invalid_native_tool_json_count == 0 && calls.is_empty() {
let (fallback_text, fallback_calls) = parse_tool_calls(&response_text);
if !fallback_text.is_empty() {
parsed_text = fallback_text;
@@ -972,20 +1742,26 @@ pub(crate) async fn run_tool_call_loop(
calls = fallback_calls;
}
- let parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
+ let mut parse_issue = detect_tool_call_parse_issue(&response_text, &calls);
+ if parse_issue.is_none() && invalid_native_tool_json_count > 0 {
+ parse_issue = Some(format!(
+ "provider returned {invalid_native_tool_json_count} native tool call(s) with invalid JSON arguments"
+ ));
+ }
if let Some(parse_issue) = parse_issue.as_deref() {
runtime_trace::record_event(
"tool_call_parse_issue",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
- Some(&parse_issue),
+ Some(parse_issue),
serde_json::json!({
"iteration": iteration + 1,
+ "invalid_native_tool_json_count": invalid_native_tool_json_count,
"response_excerpt": truncate_with_ellipsis(
- &scrub_credentials(&response_text),
+ &redact_trace_text(&response_text),
600
),
}),
@@ -996,7 +1772,7 @@ pub(crate) async fn run_tool_call_loop(
"llm_response",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(true),
None,
@@ -1005,16 +1781,18 @@ pub(crate) async fn run_tool_call_loop(
"duration_ms": llm_started_at.elapsed().as_millis(),
"input_tokens": resp_input_tokens,
"output_tokens": resp_output_tokens,
- "raw_response": scrub_credentials(&response_text),
- "native_tool_calls": resp.tool_calls.len(),
+ "raw_response": redact_trace_text(&response_text),
+ "native_tool_calls": native_calls.len(),
"parsed_tool_calls": calls.len(),
+ "continuation_attempts": continuation_attempts,
+ "stop_reason": stop_reason.as_ref().map(stop_reason_name),
+ "raw_stop_reason": raw_stop_reason,
}),
);
// Preserve native tool call IDs in assistant history so role=tool
// follow-up messages can reference the exact call id.
- let reasoning_content = resp.reasoning_content.clone();
- let assistant_history_content = if resp.tool_calls.is_empty() {
+ let assistant_history_content = if native_calls.is_empty() {
if use_native_tools {
build_native_assistant_history_from_parsed_calls(
&response_text,
@@ -1028,12 +1806,11 @@ pub(crate) async fn run_tool_call_loop(
} else {
build_native_assistant_history(
&response_text,
- &resp.tool_calls,
+ &native_calls,
reasoning_content.as_deref(),
)
};
- let native_calls = resp.tool_calls;
(
response_text,
parsed_text,
@@ -1047,7 +1824,7 @@ pub(crate) async fn run_tool_call_loop(
let safe_error = crate::providers::sanitize_api_error(&e.to_string());
observer.record_event(&ObserverEvent::LlmResponse {
provider: provider_name.to_string(),
- model: model.to_string(),
+ model: active_model.clone(),
duration: llm_started_at.elapsed(),
success: false,
error_message: Some(safe_error.clone()),
@@ -1058,7 +1835,7 @@ pub(crate) async fn run_tool_call_loop(
"llm_response",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some(&safe_error),
@@ -1077,25 +1854,55 @@ pub(crate) async fn run_tool_call_loop(
parsed_text
};
+ let canary_exfiltration_detected = canary_guard
+ .response_contains_canary(&response_text, turn_canary_token.as_deref())
+ || canary_guard.response_contains_canary(&display_text, turn_canary_token.as_deref());
+ if canary_exfiltration_detected {
+ runtime_trace::record_event(
+ "security_canary_exfiltration_blocked",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(false),
+ Some("llm output contained turn canary token"),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "response_excerpt": truncate_with_ellipsis(&redact_trace_text(&display_text), 600),
+ }),
+ );
+ if let Some(ref tx) = on_delta {
+ let _ = tx.send(DRAFT_CLEAR_SENTINEL.to_string()).await;
+ let _ = tx.send(CANARY_EXFILTRATION_BLOCK_MESSAGE.to_string()).await;
+ }
+ history.push(ChatMessage::assistant(
+ CANARY_EXFILTRATION_BLOCK_MESSAGE.to_string(),
+ ));
+ return Ok(CANARY_EXFILTRATION_BLOCK_MESSAGE.to_string());
+ }
+
// ── Progress: LLM responded ─────────────────────────────
- if let Some(ref tx) = on_delta {
- let llm_secs = llm_started_at.elapsed().as_secs();
- if !tool_calls.is_empty() {
- let _ = tx
- .send(format!(
- "{DRAFT_PROGRESS_SENTINEL}\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
- tool_calls.len()
- ))
- .await;
+ if should_emit_verbose_progress(progress_mode) {
+ if let Some(ref tx) = on_delta {
+ let llm_secs = llm_started_at.elapsed().as_secs();
+ if !tool_calls.is_empty() {
+ let _ = tx
+ .send(format!(
+ "{DRAFT_PROGRESS_SENTINEL}\u{1f4ac} Got {} tool call(s) ({llm_secs}s)\n",
+ tool_calls.len()
+ ))
+ .await;
+ }
}
}
if tool_calls.is_empty() {
+ let missing_tool_call_signal =
+ parse_issue_detected || looks_like_deferred_action_without_tool_call(&display_text);
let missing_tool_call_followthrough = !missing_tool_call_retry_used
&& iteration + 1 < max_iterations
&& !tool_specs.is_empty()
- && (parse_issue_detected
- || looks_like_deferred_action_without_tool_call(&display_text));
+ && missing_tool_call_signal;
if missing_tool_call_followthrough {
missing_tool_call_retry_used = true;
missing_tool_call_retry_prompt = Some(MISSING_TOOL_CALL_RETRY_PROMPT.to_string());
@@ -1109,39 +1916,60 @@ pub(crate) async fn run_tool_call_loop(
"tool_call_followthrough_retry",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(true),
Some("llm response implied follow-up action but emitted no tool call"),
serde_json::json!({
"iteration": iteration + 1,
"reason": retry_reason,
- "response_excerpt": truncate_with_ellipsis(&scrub_credentials(&display_text), 600),
+ "response_excerpt": truncate_with_ellipsis(&redact_trace_text(&display_text), 600),
}),
);
- if let Some(ref tx) = on_delta {
- let _ = tx
- .send(format!(
- "{DRAFT_PROGRESS_SENTINEL}\u{21bb} Retrying: response deferred action without a tool call\n"
- ))
- .await;
+ if should_emit_verbose_progress(progress_mode) {
+ if let Some(ref tx) = on_delta {
+ let _ = tx
+ .send(format!(
+ "{DRAFT_PROGRESS_SENTINEL}\u{21bb} Retrying: response deferred action without a tool call\n"
+ ))
+ .await;
+ }
}
continue;
}
+ if missing_tool_call_retry_used && !tool_specs.is_empty() && missing_tool_call_signal {
+ runtime_trace::record_event(
+ "tool_call_followthrough_failed",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ Some(&turn_id),
+ Some(false),
+ Some("llm response still implied follow-up action but emitted no tool call after retry"),
+ serde_json::json!({
+ "iteration": iteration + 1,
+ "response_excerpt": truncate_with_ellipsis(&redact_trace_text(&display_text), 600),
+ }),
+ );
+ anyhow::bail!(
+ "Model deferred action without emitting a tool call after retry; refusing to return unverified completion."
+ );
+ }
+
runtime_trace::record_event(
"turn_final_response",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(true),
None,
serde_json::json!({
"iteration": iteration + 1,
- "text": scrub_credentials(&display_text),
+ "text": redact_trace_text(&display_text),
}),
);
// No tool calls — this is the final response.
@@ -1193,6 +2021,7 @@ pub(crate) async fn run_tool_call_loop(
let allow_parallel_execution = should_execute_tools_in_parallel(&tool_calls, approval);
let mut executable_indices: Vec = Vec::new();
let mut executable_calls: Vec = Vec::new();
+ let mut progress_indices: Vec> = Vec::new();
for (idx, call) in tool_calls.iter().enumerate() {
// ── Hook: before_tool_call (modifying) ──────────
@@ -1210,7 +2039,7 @@ pub(crate) async fn run_tool_call_loop(
"tool_call_result",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some(&cancelled),
@@ -1244,6 +2073,8 @@ pub(crate) async fn run_tool_call_loop(
&mut tool_args,
channel_name,
channel_reply_target.as_deref(),
+ provider_name,
+ active_model.as_str(),
);
if excluded_tools.iter().any(|ex| ex == &tool_name) {
@@ -1252,7 +2083,7 @@ pub(crate) async fn run_tool_call_loop(
"tool_call_result",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some(&blocked),
@@ -1280,29 +2111,38 @@ pub(crate) async fn run_tool_call_loop(
if let Some(mgr) = approval {
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 {
+ let requires_interactive_approval =
+ mgr.needs_approval_for_call(&tool_name, &tool_args);
+ if bypass_non_cli_approval_for_turn {
+ // One-time bypass token: bypass ALL approvals (including interactive)
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) {
+ } else if non_cli_session_granted && !requires_interactive_approval {
+ // Session grant: bypass only non-interactive approvals
+ mgr.record_decision(
+ &tool_name,
+ &tool_args,
+ ApprovalResponse::Yes,
+ channel_name,
+ );
+ runtime_trace::record_event(
+ "approval_bypass_non_cli_session_grant",
+ Some(channel_name),
+ Some(provider_name),
+ Some(active_model.as_str()),
+ 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 requires_interactive_approval {
let request = ApprovalRequest {
tool_name: tool_name.clone(),
arguments: tool_args.clone(),
@@ -1349,7 +2189,7 @@ pub(crate) async fn run_tool_call_loop(
"tool_call_result",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some(&denied),
@@ -1371,6 +2211,24 @@ pub(crate) async fn run_tool_call_loop(
));
continue;
}
+
+ if matches!(decision, ApprovalResponse::Yes | ApprovalResponse::Always) {
+ match &mut tool_args {
+ serde_json::Value::Object(map) => {
+ map.insert("approved".to_string(), serde_json::Value::Bool(true));
+ }
+ serde_json::Value::String(command) => {
+ let normalized_command = command.trim().to_string();
+ if !normalized_command.is_empty() {
+ tool_args = serde_json::json!({
+ "command": normalized_command,
+ "approved": true
+ });
+ }
+ }
+ _ => {}
+ }
+ }
}
}
@@ -1383,7 +2241,7 @@ pub(crate) async fn run_tool_call_loop(
"tool_call_result",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some(&duplicate),
@@ -1411,7 +2269,7 @@ pub(crate) async fn run_tool_call_loop(
"tool_call_start",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
None,
None,
@@ -1422,19 +2280,17 @@ pub(crate) async fn run_tool_call_loop(
}),
);
- // ── Progress: tool start ────────────────────────────
- if let Some(ref tx) = on_delta {
+ let progress_idx = if should_emit_tool_progress(progress_mode) {
let hint = truncate_tool_args_for_progress(&tool_name, &tool_args, 60);
- let progress = if hint.is_empty() {
- format!("\u{23f3} {}\n", tool_name)
- } else {
- format!("\u{23f3} {}: {hint}\n", tool_name)
- };
- tracing::debug!(tool = %tool_name, "Sending progress start to draft");
- let _ = tx
- .send(format!("{DRAFT_PROGRESS_SENTINEL}{progress}"))
- .await;
- }
+ let idx = progress_tracker.add(&tool_name, &hint);
+ if let Some(ref tx) = on_delta {
+ tracing::debug!(tool = %tool_name, "Sending progress start to draft");
+ let _ = tx.send(progress_tracker.render_delta()).await;
+ }
+ Some(idx)
+ } else {
+ None
+ };
executable_indices.push(idx);
executable_calls.push(ParsedToolCall {
@@ -1442,6 +2298,7 @@ pub(crate) async fn run_tool_call_loop(
arguments: tool_args,
tool_call_id: call.tool_call_id.clone(),
});
+ progress_indices.push(progress_idx);
}
let executed_outcomes = if allow_parallel_execution && executable_calls.len() > 1 {
@@ -1462,16 +2319,17 @@ pub(crate) async fn run_tool_call_loop(
.await?
};
- for ((idx, call), outcome) in executable_indices
+ for (((idx, call), mut outcome), progress_idx) in executable_indices
.iter()
.zip(executable_calls.iter())
.zip(executed_outcomes.into_iter())
+ .zip(progress_indices.iter())
{
runtime_trace::record_event(
"tool_call_result",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(outcome.success),
outcome.error_reason.as_deref(),
@@ -1485,31 +2343,42 @@ pub(crate) async fn run_tool_call_loop(
// ── Hook: after_tool_call (void) ─────────────────
if let Some(hooks) = hooks {
- let tool_result_obj = crate::tools::ToolResult {
+ let mut tool_result_obj = crate::tools::ToolResult {
success: outcome.success,
output: outcome.output.clone(),
- error: None,
+ error: outcome.error_reason.clone(),
};
+ match hooks
+ .run_tool_result_persist(call.name.clone(), tool_result_obj.clone())
+ .await
+ {
+ crate::hooks::HookResult::Continue(next) => {
+ tool_result_obj = next;
+ outcome.success = tool_result_obj.success;
+ outcome.output = tool_result_obj.output.clone();
+ outcome.error_reason = tool_result_obj.error.clone();
+ }
+ crate::hooks::HookResult::Cancel(reason) => {
+ outcome.success = false;
+ outcome.error_reason = Some(scrub_credentials(&reason));
+ outcome.output = format!("Tool result blocked by hook: {reason}");
+ tool_result_obj.success = false;
+ tool_result_obj.error = Some(reason);
+ tool_result_obj.output = outcome.output.clone();
+ }
+ }
hooks
.fire_after_tool_call(&call.name, &tool_result_obj, outcome.duration)
.await;
}
- // ── Progress: tool completion ───────────────────────
- if let Some(ref tx) = on_delta {
+ if let Some(idx) = progress_idx {
let secs = outcome.duration.as_secs();
- let icon = if outcome.success {
- "\u{2705}"
- } else {
- "\u{274c}"
- };
- tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
- let _ = tx
- .send(format!(
- "{DRAFT_PROGRESS_SENTINEL}{icon} {} ({secs}s)\n",
- call.name
- ))
- .await;
+ progress_tracker.complete(*idx, outcome.success, secs);
+ if let Some(ref tx) = on_delta {
+ tracing::debug!(tool = %call.name, secs, "Sending progress complete to draft");
+ let _ = tx.send(progress_tracker.render_delta()).await;
+ }
}
// ── Loop detection: record call ──────────────────────
@@ -1572,18 +2441,20 @@ pub(crate) async fn run_tool_call_loop(
"loop_detected_warning",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some("loop pattern detected, injecting self-correction prompt"),
serde_json::json!({ "iteration": iteration + 1, "warning": &warning }),
);
- if let Some(ref tx) = on_delta {
- let _ = tx
- .send(format!(
- "{DRAFT_PROGRESS_SENTINEL}\u{26a0}\u{fe0f} Loop detected, attempting self-correction\n"
- ))
- .await;
+ if should_emit_verbose_progress(progress_mode) {
+ if let Some(ref tx) = on_delta {
+ let _ = tx
+ .send(format!(
+ "{DRAFT_PROGRESS_SENTINEL}\u{26a0}\u{fe0f} Loop detected, attempting self-correction\n"
+ ))
+ .await;
+ }
}
loop_detection_prompt = Some(warning);
}
@@ -1592,7 +2463,7 @@ pub(crate) async fn run_tool_call_loop(
"loop_detected_hard_stop",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some("loop persisted after warning, stopping early"),
@@ -1612,7 +2483,7 @@ pub(crate) async fn run_tool_call_loop(
"tool_loop_exhausted",
Some(channel_name),
Some(provider_name),
- Some(model),
+ Some(active_model.as_str()),
Some(&turn_id),
Some(false),
Some("agent exceeded maximum tool iterations"),
@@ -1742,6 +2613,12 @@ pub(crate) fn build_shell_policy_instructions(autonomy: &crate::config::Autonomy
// and hard trimming to keep the context window bounded.
#[allow(clippy::too_many_lines)]
+/// Run the agent loop with the given configuration.
+///
+/// When `hooks` is `Some`, the supplied [`HookRunner`](crate::hooks::HookRunner)
+/// is invoked at every tool-call boundary (`before_tool_call` /
+/// `on_after_tool_call`), enabling library consumers to inject safety,
+/// audit, or transformation logic without patching the crate.
pub async fn run(
config: Config,
message: Option,
@@ -1750,10 +2627,18 @@ pub async fn run(
temperature: f64,
peripheral_overrides: Vec,
interactive: bool,
+ hooks: Option<&crate::hooks::HookRunner>,
) -> Result {
+ if let Err(error) = crate::plugins::runtime::initialize_from_config(&config.plugins) {
+ tracing::warn!("plugin registry initialization skipped: {error}");
+ }
+
// ── Wire up agnostic subsystems ──────────────────────────────
- let base_observer = observability::create_observer(&config.observability);
- let observer: Arc = Arc::from(base_observer);
+ let base_observer: Arc =
+ Arc::from(observability::create_observer(&config.observability));
+ let observer: Arc = Arc::new(
+ crate::plugins::bridge::observer::ObserverBridge::new(base_observer),
+ );
let runtime: Arc =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
@@ -1809,6 +2694,7 @@ pub async fn run(
tracing::info!(count = peripheral_tools.len(), "Peripheral tools added");
tools_registry.extend(peripheral_tools);
}
+ let tools_registry = filter_primary_agent_tools_or_fail(&config, tools_registry)?;
// ── Resolve provider ─────────────────────────────────────────
let provider_name = provider_override
@@ -1832,6 +2718,7 @@ pub async fn run(
reasoning_enabled: config.runtime.reasoning_enabled,
reasoning_level: config.effective_provider_reasoning_level(),
custom_provider_api_mode: config.provider_api.map(|mode| mode.as_compatible_mode()),
+ custom_provider_auth_header: config.effective_custom_provider_auth_header(),
max_tokens_override: None,
model_support_vision: config.model_support_vision,
};
@@ -1997,6 +2884,7 @@ pub async fn run(
"Query connected hardware for reported GPIO pins and LED pin. Use when: user asks what pins are available.",
));
}
+ retain_visible_tool_descriptions(&mut tool_descs, &tools_registry);
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@@ -2020,6 +2908,9 @@ pub async fn run(
}
system_prompt.push_str(&build_shell_policy_instructions(&config.autonomy));
+ let configured_hooks = crate::hooks::create_runner_from_config(&config.hooks);
+ let effective_hooks = hooks.or(configured_hooks.as_deref());
+
// ── Approval manager (supervised mode) ───────────────────────
let approval_manager = if interactive {
Some(ApprovalManager::from_config(&config.autonomy))
@@ -2030,6 +2921,8 @@ pub async fn run(
// ── Execute ──────────────────────────────────────────────────
let start = Instant::now();
+ let cost_enforcement_context =
+ create_cost_enforcement_context(&config.cost, &config.workspace_dir);
let mut final_output = String::new();
@@ -2076,32 +2969,37 @@ pub async fn run(
} else {
None
};
- let response = SAFETY_HEARTBEAT_CONFIG
- .scope(
+ let response = scope_cost_enforcement_context(
+ cost_enforcement_context.clone(),
+ SAFETY_HEARTBEAT_CONFIG.scope(
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,
- &[],
+ TOOL_LOOP_CANARY_TOKENS_ENABLED.scope(
+ config.security.canary_tokens,
+ 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,
+ effective_hooks,
+ &[],
+ ),
),
),
- )
- .await?;
+ ),
+ )
+ .await?;
final_output = response.clone();
if config.memory.auto_save && response.chars().count() >= AUTOSAVE_MIN_MESSAGE_CHARS {
let assistant_key = autosave_memory_key("assistant_resp");
@@ -2116,6 +3014,19 @@ pub async fn run(
}
println!("{response}");
observer.record_event(&ObserverEvent::TurnComplete);
+
+ // ── Post-turn fact extraction (single-message mode) ────────
+ if config.memory.auto_save {
+ let turns = vec![(msg.clone(), response.clone())];
+ let _ = extract_facts_from_turns(
+ provider.as_ref(),
+ &model_name,
+ &turns,
+ mem.as_ref(),
+ None,
+ )
+ .await;
+ }
} else {
println!("🦀 ZeroClaw Interactive Mode");
println!("Type /help for commands.\n");
@@ -2124,6 +3035,7 @@ pub async fn run(
// Persistent conversation history across turns
let mut history = vec![ChatMessage::system(&system_prompt)];
let mut interactive_turn: usize = 0;
+ let mut turn_buffer = TurnBuffer::new();
// Reusable readline editor for UTF-8 input support
let mut rl = Editor::with_config(
RlConfig::builder()
@@ -2136,6 +3048,18 @@ pub async fn run(
let input = match rl.readline("> ") {
Ok(line) => line,
Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
+ // Flush any remaining buffered turns before exit.
+ if config.memory.auto_save && !turn_buffer.is_empty() {
+ let turns = turn_buffer.drain_for_extraction();
+ let _ = extract_facts_from_turns(
+ provider.as_ref(),
+ &model_name,
+ &turns,
+ mem.as_ref(),
+ None,
+ )
+ .await;
+ }
break;
}
Err(e) => {
@@ -2150,7 +3074,21 @@ pub async fn run(
}
rl.add_history_entry(&input)?;
match user_input.as_str() {
- "/quit" | "/exit" => break,
+ "/quit" | "/exit" => {
+ // Flush any remaining buffered turns before exit.
+ if config.memory.auto_save && !turn_buffer.is_empty() {
+ let turns = turn_buffer.drain_for_extraction();
+ let _ = extract_facts_from_turns(
+ provider.as_ref(),
+ &model_name,
+ &turns,
+ mem.as_ref(),
+ None,
+ )
+ .await;
+ }
+ break;
+ }
"/help" => {
println!("Available commands:");
println!(" /help Show this help message");
@@ -2175,6 +3113,7 @@ pub async fn run(
history.clear();
history.push(ChatMessage::system(&system_prompt));
interactive_turn = 0;
+ turn_buffer = TurnBuffer::new();
// Clear conversation and daily memory
let mut cleared = 0;
for category in [MemoryCategory::Conversation, MemoryCategory::Daily] {
@@ -2224,6 +3163,12 @@ pub async fn run(
format!("{context}[{now}] {user_input}")
};
+ if let Some(system_message) = history.first_mut() {
+ if system_message.role == "system" {
+ crate::agent::prompt::refresh_prompt_datetime(&mut system_message.content);
+ }
+ }
+
history.push(ChatMessage::user(&enriched));
interactive_turn += 1;
@@ -2253,32 +3198,37 @@ pub async fn run(
} else {
None
};
- let response = match SAFETY_HEARTBEAT_CONFIG
- .scope(
+ let response = match scope_cost_enforcement_context(
+ cost_enforcement_context.clone(),
+ SAFETY_HEARTBEAT_CONFIG.scope(
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,
- &[],
+ TOOL_LOOP_CANARY_TOKENS_ENABLED.scope(
+ config.security.canary_tokens,
+ 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,
+ effective_hooks,
+ &[],
+ ),
),
),
- )
- .await
+ ),
+ )
+ .await
{
Ok(resp) => resp,
Err(e) => {
@@ -2327,16 +3277,58 @@ pub async fn run(
}
observer.record_event(&ObserverEvent::TurnComplete);
+ // ── Post-turn fact extraction ────────────────────────────
+ if config.memory.auto_save {
+ turn_buffer.push(&user_input, &response);
+ if turn_buffer.should_extract() {
+ let turns = turn_buffer.drain_for_extraction();
+ let result = extract_facts_from_turns(
+ provider.as_ref(),
+ &model_name,
+ &turns,
+ mem.as_ref(),
+ None,
+ )
+ .await;
+ if result.stored > 0 || result.no_facts {
+ turn_buffer.mark_extract_success();
+ }
+ }
+ }
+
// Auto-compaction before hard trimming to preserve long-context signal.
- if let Ok(compacted) = auto_compact_history(
+ // post_turn_active is only true when auto_save is on AND the
+ // turn buffer confirms recent extraction succeeded; otherwise
+ // compaction must fall back to its own flush_durable_facts.
+ let post_turn_active =
+ config.memory.auto_save && !turn_buffer.needs_compaction_fallback();
+ if let Ok((compacted, flush_ok)) = auto_compact_history(
&mut history,
provider.as_ref(),
&model_name,
config.agent.max_history_messages,
+ effective_hooks,
+ Some(mem.as_ref()),
+ None,
+ post_turn_active,
)
.await
{
if compacted {
+ if !post_turn_active {
+ // Compaction ran its own flush_durable_facts as
+ // fallback. Drain any buffered turns to prevent
+ // duplicate extraction.
+ if !turn_buffer.is_empty() {
+ let _ = turn_buffer.drain_for_extraction();
+ }
+ // Only reset the failure flag when the fallback
+ // flush actually succeeded; otherwise keep the
+ // flag so subsequent compactions retry.
+ if flush_ok {
+ turn_buffer.mark_extract_success();
+ }
+ }
println!("🧹 Auto-compaction complete");
}
}
@@ -2369,8 +3361,14 @@ pub async fn process_message_with_session(
message: &str,
session_id: Option<&str>,
) -> Result {
- let observer: Arc =
+ if let Err(error) = crate::plugins::runtime::initialize_from_config(&config.plugins) {
+ tracing::warn!("plugin registry initialization skipped: {error}");
+ }
+ let base_observer: Arc =
Arc::from(observability::create_observer(&config.observability));
+ let observer: Arc = Arc::new(
+ crate::plugins::bridge::observer::ObserverBridge::new(base_observer),
+ );
let runtime: Arc =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
@@ -2410,6 +3408,7 @@ pub async fn process_message_with_session(
let peripheral_tools: Vec> =
crate::peripherals::create_peripheral_tools(&config.peripherals).await?;
tools_registry.extend(peripheral_tools);
+ let tools_registry = filter_primary_agent_tools_or_fail(&config, tools_registry)?;
let provider_name = config.default_provider.as_deref().unwrap_or("openrouter");
let model_name = crate::config::resolve_default_model_id(
@@ -2425,6 +3424,7 @@ pub async fn process_message_with_session(
reasoning_enabled: config.runtime.reasoning_enabled,
reasoning_level: config.effective_provider_reasoning_level(),
custom_provider_api_mode: config.provider_api.map(|mode| mode.as_compatible_mode()),
+ custom_provider_auth_header: config.effective_custom_provider_auth_header(),
max_tokens_override: None,
model_support_vision: config.model_support_vision,
};
@@ -2511,6 +3511,7 @@ pub async fn process_message_with_session(
"Query connected hardware for reported GPIO pins and LED pin. Use when user asks what pins are available.",
));
}
+ retain_visible_tool_descriptions(&mut tool_descs, &tools_registry);
let bootstrap_max_chars = if config.agent.compact_context {
Some(6000)
} else {
@@ -2557,6 +3558,8 @@ pub async fn process_message_with_session(
ChatMessage::user(&enriched),
];
+ let cost_enforcement_context =
+ create_cost_enforcement_context(&config.cost, &config.workspace_dir);
let hb_cfg = if config.agent.safety_heartbeat_interval > 0 {
Some(SafetyHeartbeatConfig {
body: security.summary_for_heartbeat(),
@@ -2565,8 +3568,9 @@ pub async fn process_message_with_session(
} else {
None
};
- SAFETY_HEARTBEAT_CONFIG
- .scope(
+ let response = scope_cost_enforcement_context(
+ cost_enforcement_context,
+ SAFETY_HEARTBEAT_CONFIG.scope(
hb_cfg,
agent_turn(
provider.as_ref(),
@@ -2580,8 +3584,24 @@ pub async fn process_message_with_session(
&config.multimodal,
config.agent.max_tool_iterations,
),
+ ),
+ )
+ .await?;
+
+ // ── Post-turn fact extraction (channel / single-message-with-session) ──
+ if config.memory.auto_save {
+ let turns = vec![(message.to_owned(), response.clone())];
+ let _ = extract_facts_from_turns(
+ provider.as_ref(),
+ &model_name,
+ &turns,
+ mem.as_ref(),
+ session_id,
)
- .await
+ .await;
+ }
+
+ Ok(response)
}
#[cfg(test)]
@@ -2590,7 +3610,7 @@ mod tests {
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD, Engine as _};
use std::collections::VecDeque;
- use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -2620,11 +3640,19 @@ mod tests {
"prompt": "remind me later"
});
- maybe_inject_cron_add_delivery("cron_add", &mut args, "telegram", Some("-10012345"));
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut args,
+ "telegram",
+ Some("-10012345"),
+ "custom:https://llm.example.com/v1",
+ "gpt-oss:20b",
+ );
assert_eq!(args["delivery"]["mode"], "announce");
assert_eq!(args["delivery"]["channel"], "telegram");
assert_eq!(args["delivery"]["to"], "-10012345");
+ assert_eq!(args["model"], "gpt-oss:20b");
}
#[test]
@@ -2639,7 +3667,14 @@ mod tests {
}
});
- maybe_inject_cron_add_delivery("cron_add", &mut args, "telegram", Some("-10012345"));
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut args,
+ "telegram",
+ Some("-10012345"),
+ "openrouter",
+ "anthropic/claude-sonnet-4.6",
+ );
assert_eq!(args["delivery"]["channel"], "discord");
assert_eq!(args["delivery"]["to"], "C123");
@@ -2652,7 +3687,14 @@ mod tests {
"command": "echo hello"
});
- maybe_inject_cron_add_delivery("cron_add", &mut args, "telegram", Some("-10012345"));
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut args,
+ "telegram",
+ Some("-10012345"),
+ "openrouter",
+ "anthropic/claude-sonnet-4.6",
+ );
assert!(args.get("delivery").is_none());
}
@@ -2663,7 +3705,14 @@ mod tests {
"job_type": "agent",
"prompt": "daily summary"
});
- maybe_inject_cron_add_delivery("cron_add", &mut lark_args, "lark", Some("oc_xxx"));
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut lark_args,
+ "lark",
+ Some("oc_xxx"),
+ "openrouter",
+ "anthropic/claude-sonnet-4.6",
+ );
assert_eq!(lark_args["delivery"]["channel"], "lark");
assert_eq!(lark_args["delivery"]["to"], "oc_xxx");
@@ -2671,11 +3720,58 @@ mod tests {
"job_type": "agent",
"prompt": "daily summary"
});
- maybe_inject_cron_add_delivery("cron_add", &mut feishu_args, "feishu", Some("oc_yyy"));
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut feishu_args,
+ "feishu",
+ Some("oc_yyy"),
+ "openrouter",
+ "anthropic/claude-sonnet-4.6",
+ );
assert_eq!(feishu_args["delivery"]["channel"], "feishu");
assert_eq!(feishu_args["delivery"]["to"], "oc_yyy");
}
+ #[test]
+ fn maybe_inject_cron_add_delivery_replaces_legacy_model_on_custom_provider() {
+ let mut args = serde_json::json!({
+ "job_type": "agent",
+ "prompt": "remind me later",
+ "model": "gpt-4o-mini"
+ });
+
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut args,
+ "discord",
+ Some("C123"),
+ "custom:https://somecoolai.endpoint.lan/api/v1",
+ "gpt-oss:20b",
+ );
+
+ assert_eq!(args["model"], "gpt-oss:20b");
+ }
+
+ #[test]
+ fn maybe_inject_cron_add_delivery_keeps_explicit_model_for_non_custom_provider() {
+ let mut args = serde_json::json!({
+ "job_type": "agent",
+ "prompt": "remind me later",
+ "model": "gpt-4o-mini"
+ });
+
+ maybe_inject_cron_add_delivery(
+ "cron_add",
+ &mut args,
+ "discord",
+ Some("C123"),
+ "openrouter",
+ "anthropic/claude-sonnet-4.6",
+ );
+
+ assert_eq!(args["model"], "gpt-4o-mini");
+ }
+
#[test]
fn safety_heartbeat_interval_zero_disables_injection() {
for counter in [0, 1, 2, 10, 100] {
@@ -2776,6 +3872,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
})
}
}
@@ -2786,6 +3884,13 @@ mod tests {
}
impl ScriptedProvider {
+ fn from_scripted_responses(responses: Vec) -> Self {
+ Self {
+ responses: Arc::new(Mutex::new(VecDeque::from(responses))),
+ capabilities: ProviderCapabilities::default(),
+ }
+ }
+
fn from_text_responses(responses: Vec<&str>) -> Self {
let scripted = responses
.into_iter()
@@ -2795,12 +3900,11 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
})
.collect();
- Self {
- responses: Arc::new(Mutex::new(scripted)),
- capabilities: ProviderCapabilities::default(),
- }
+ Self::from_scripted_responses(scripted)
}
fn with_native_tool_support(mut self) -> Self {
@@ -2841,6 +3945,54 @@ mod tests {
}
}
+ struct EchoCanaryProvider;
+
+ #[async_trait]
+ impl Provider for EchoCanaryProvider {
+ fn capabilities(&self) -> ProviderCapabilities {
+ ProviderCapabilities::default()
+ }
+
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ anyhow::bail!("chat_with_system should not be used in canary provider tests");
+ }
+
+ async fn chat(
+ &self,
+ request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ let canary = request
+ .messages
+ .iter()
+ .find(|msg| msg.role == "system")
+ .and_then(|msg| {
+ msg.content.lines().find_map(|line| {
+ line.trim()
+ .strip_prefix("Internal security canary token: ")
+ .map(str::trim)
+ })
+ })
+ .unwrap_or("NO_CANARY");
+ Ok(ChatResponse {
+ text: Some(format!("Leaking token for test: {canary}")),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
struct CountingTool {
name: String,
invocations: Arc,
@@ -2898,6 +4050,16 @@ mod tests {
max_active: Arc,
}
+ struct ApprovalFlagTool {
+ approved_seen: Arc,
+ }
+
+ impl ApprovalFlagTool {
+ fn new(approved_seen: Arc) -> Self {
+ Self { approved_seen }
+ }
+ }
+
impl DelayTool {
fn new(
name: &str,
@@ -2914,6 +4076,60 @@ mod tests {
}
}
+ struct FailingTool;
+
+ #[async_trait]
+ impl Tool for FailingTool {
+ fn name(&self) -> &str {
+ "failing_tool"
+ }
+
+ fn description(&self) -> &str {
+ "Fails deterministically for error-propagation tests"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {}
+ })
+ }
+
+ async fn execute(
+ &self,
+ _args: serde_json::Value,
+ ) -> anyhow::Result {
+ Ok(crate::tools::ToolResult {
+ success: false,
+ output: String::new(),
+ error: Some("boom".to_string()),
+ })
+ }
+ }
+
+ struct ErrorCaptureHook {
+ seen_errors: Arc>>>,
+ }
+
+ #[async_trait]
+ impl crate::hooks::HookHandler for ErrorCaptureHook {
+ fn name(&self) -> &str {
+ "error-capture"
+ }
+
+ async fn on_after_tool_call(
+ &self,
+ _tool: &str,
+ result: &crate::tools::ToolResult,
+ _duration: Duration,
+ ) {
+ self.seen_errors
+ .lock()
+ .expect("hook error buffer lock should be valid")
+ .push(result.error.clone());
+ }
+ }
+
#[async_trait]
impl Tool for DelayTool {
fn name(&self) -> &str {
@@ -2959,6 +4175,44 @@ mod tests {
}
}
+ #[async_trait]
+ impl Tool for ApprovalFlagTool {
+ fn name(&self) -> &str {
+ "shell"
+ }
+
+ fn description(&self) -> &str {
+ "Captures the approved flag for approval-flow tests"
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {
+ "command": { "type": "string" },
+ "approved": { "type": "boolean" }
+ },
+ "required": ["command"]
+ })
+ }
+
+ async fn execute(
+ &self,
+ args: serde_json::Value,
+ ) -> anyhow::Result {
+ let approved = args
+ .get("approved")
+ .and_then(serde_json::Value::as_bool)
+ .unwrap_or(false);
+ self.approved_seen.store(approved, Ordering::SeqCst);
+ Ok(crate::tools::ToolResult {
+ success: approved,
+ output: format!("approved={approved}"),
+ error: (!approved).then(|| "missing approved=true".to_string()),
+ })
+ }
+ }
+
#[tokio::test]
async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
let calls = Arc::new(AtomicUsize::new(0));
@@ -3031,6 +4285,87 @@ mod tests {
assert_eq!(result, "vision-ok");
}
+ #[tokio::test]
+ async fn run_tool_call_loop_blocks_when_canary_token_is_echoed() {
+ let provider = EchoCanaryProvider;
+ let mut history = vec![
+ ChatMessage::system("system prompt"),
+ ChatMessage::user("hello".to_string()),
+ ];
+ let tools_registry: Vec> = Vec::new();
+ let observer = NoopObserver;
+
+ let result = TOOL_LOOP_CANARY_TOKENS_ENABLED
+ .scope(
+ true,
+ run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 3,
+ None,
+ None,
+ None,
+ &[],
+ ),
+ )
+ .await
+ .expect("canary leak should return a guarded message");
+
+ assert_eq!(result, CANARY_EXFILTRATION_BLOCK_MESSAGE);
+ assert_eq!(
+ history.last().map(|msg| msg.content.as_str()),
+ Some(result.as_str())
+ );
+ assert!(history[0].content.contains("ZC_CANARY_START"));
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_allows_echo_provider_when_canary_guard_disabled() {
+ let provider = EchoCanaryProvider;
+ let mut history = vec![
+ ChatMessage::system("system prompt"),
+ ChatMessage::user("hello".to_string()),
+ ];
+ let tools_registry: Vec> = Vec::new();
+ let observer = NoopObserver;
+
+ let result = TOOL_LOOP_CANARY_TOKENS_ENABLED
+ .scope(
+ false,
+ run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 3,
+ None,
+ None,
+ None,
+ &[],
+ ),
+ )
+ .await
+ .expect("without canary guard, response should pass through");
+
+ assert!(result.contains("NO_CANARY"));
+ }
+
#[tokio::test]
async fn run_tool_call_loop_rejects_oversized_image_payload() {
let calls = Arc::new(AtomicUsize::new(0));
@@ -3176,6 +4511,76 @@ mod tests {
));
}
+ #[test]
+ fn should_execute_tools_in_parallel_returns_false_when_command_rule_requires_approval() {
+ let calls = vec![
+ ParsedToolCall {
+ name: "shell".to_string(),
+ arguments: serde_json::json!({"command": "rm -f ./tmp.txt"}),
+ tool_call_id: None,
+ },
+ ParsedToolCall {
+ name: "file_read".to_string(),
+ arguments: serde_json::json!({"path": "README.md"}),
+ tool_call_id: None,
+ },
+ ];
+ let approval_cfg = crate::config::AutonomyConfig {
+ auto_approve: vec!["shell".to_string(), "file_read".to_string()],
+ always_ask: vec![],
+ command_context_rules: vec![crate::config::CommandContextRuleConfig {
+ command: "rm".to_string(),
+ action: crate::config::CommandContextRuleAction::RequireApproval,
+ allowed_domains: vec![],
+ allowed_path_prefixes: vec![],
+ denied_path_prefixes: vec![],
+ allow_high_risk: false,
+ }],
+ ..crate::config::AutonomyConfig::default()
+ };
+ let approval_mgr = ApprovalManager::from_config(&approval_cfg);
+
+ assert!(!should_execute_tools_in_parallel(
+ &calls,
+ Some(&approval_mgr)
+ ));
+ }
+
+ #[test]
+ fn should_execute_tools_in_parallel_returns_true_when_command_rule_does_not_match() {
+ let calls = vec![
+ ParsedToolCall {
+ name: "shell".to_string(),
+ arguments: serde_json::json!({"command": "ls -la"}),
+ tool_call_id: None,
+ },
+ ParsedToolCall {
+ name: "file_read".to_string(),
+ arguments: serde_json::json!({"path": "README.md"}),
+ tool_call_id: None,
+ },
+ ];
+ let approval_cfg = crate::config::AutonomyConfig {
+ auto_approve: vec!["shell".to_string(), "file_read".to_string()],
+ always_ask: vec![],
+ command_context_rules: vec![crate::config::CommandContextRuleConfig {
+ command: "rm".to_string(),
+ action: crate::config::CommandContextRuleAction::RequireApproval,
+ allowed_domains: vec![],
+ allowed_path_prefixes: vec![],
+ denied_path_prefixes: vec![],
+ allow_high_risk: false,
+ }],
+ ..crate::config::AutonomyConfig::default()
+ };
+ let approval_mgr = ApprovalManager::from_config(&approval_cfg);
+
+ assert!(should_execute_tools_in_parallel(
+ &calls,
+ Some(&approval_mgr)
+ ));
+ }
+
#[tokio::test]
async fn run_tool_call_loop_executes_multiple_tools_with_ordered_results() {
let provider = ScriptedProvider::from_text_responses(vec![
@@ -3335,7 +4740,10 @@ mod tests {
Arc::clone(&max_active),
))];
- let approval_mgr = ApprovalManager::from_config(&crate::config::AutonomyConfig::default());
+ let approval_mgr = ApprovalManager::from_config(&crate::config::AutonomyConfig {
+ auto_approve: vec!["shell".to_string()],
+ ..crate::config::AutonomyConfig::default()
+ });
approval_mgr.grant_non_cli_session("shell");
let mut history = vec![
@@ -3442,7 +4850,9 @@ mod tests {
None,
None,
&[],
+ ProgressMode::Verbose,
None,
+ false,
)
.await
.expect("tool loop should continue after non-cli approval");
@@ -3456,6 +4866,85 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn run_tool_call_loop_injects_approved_flag_after_non_cli_approval() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ r#"
+{"name":"shell","arguments":{"command":"rm -f ./tmp.txt"}}
+ "#,
+ "done",
+ ]);
+
+ let approved_seen = Arc::new(AtomicBool::new(false));
+ let tools_registry: Vec> =
+ vec![Box::new(ApprovalFlagTool::new(Arc::clone(&approved_seen)))];
+
+ let approval_mgr = Arc::new(ApprovalManager::from_config(
+ &crate::config::AutonomyConfig::default(),
+ ));
+ let (prompt_tx, mut prompt_rx) =
+ tokio::sync::mpsc::unbounded_channel::();
+ let approval_mgr_for_task = Arc::clone(&approval_mgr);
+ let approval_task = tokio::spawn(async move {
+ let prompt = prompt_rx
+ .recv()
+ .await
+ .expect("approval prompt should arrive");
+ approval_mgr_for_task
+ .confirm_non_cli_pending_request(
+ &prompt.request_id,
+ "alice",
+ "telegram",
+ "chat-approved-flag",
+ )
+ .expect("pending approval should confirm");
+ approval_mgr_for_task
+ .record_non_cli_pending_resolution(&prompt.request_id, ApprovalResponse::Yes);
+ });
+
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run shell"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop_with_non_cli_approval_context(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ Some(approval_mgr.as_ref()),
+ "telegram",
+ Some(NonCliApprovalContext {
+ sender: "alice".to_string(),
+ reply_target: "chat-approved-flag".to_string(),
+ prompt_tx,
+ }),
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ ProgressMode::Verbose,
+ None,
+ false,
+ )
+ .await
+ .expect("tool loop should continue after non-cli approval");
+
+ approval_task.await.expect("approval task should complete");
+ assert_eq!(result, "done");
+ assert!(
+ approved_seen.load(Ordering::SeqCst),
+ "approved=true should be injected after prompt approval"
+ );
+ }
+
#[tokio::test]
async fn run_tool_call_loop_consumes_one_time_non_cli_allow_all_token() {
let provider = ScriptedProvider::from_text_responses(vec![
@@ -3747,6 +5236,588 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn run_tool_call_loop_errors_when_deferred_action_repeats_without_tool_call() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ "I'll check that right away.",
+ "Let me inspect that in detail now.",
+ ]);
+
+ let invocations = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(CountingTool::new(
+ "count_tool",
+ Arc::clone(&invocations),
+ ))];
+
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("please check the workspace"),
+ ];
+ let observer = NoopObserver;
+
+ let err = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 5,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect_err("second deferred response without tool call should hard-fail");
+
+ let err_text = err.to_string();
+ assert!(
+ err_text.contains("deferred action without emitting a tool call"),
+ "unexpected error text: {err_text}"
+ );
+ assert_eq!(
+ invocations.load(Ordering::SeqCst),
+ 0,
+ "tool should not execute when model never emits a tool call"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_retries_when_native_tool_args_are_truncated_json() {
+ let provider = ScriptedProvider::from_scripted_responses(vec![
+ ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![ToolCall {
+ id: "call_bad".to_string(),
+ name: "count_tool".to_string(),
+ arguments: "{\"value\":\"truncated\"".to_string(),
+ }],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![ToolCall {
+ id: "call_good".to_string(),
+ name: "count_tool".to_string(),
+ arguments: "{\"value\":\"fixed\"}".to_string(),
+ }],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::ToolCall),
+ raw_stop_reason: Some("tool_calls".to_string()),
+ },
+ ChatResponse {
+ text: Some("done after native retry".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::EndTurn),
+ raw_stop_reason: Some("stop".to_string()),
+ },
+ ])
+ .with_native_tool_support();
+
+ let invocations = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(CountingTool::new(
+ "count_tool",
+ Arc::clone(&invocations),
+ ))];
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run native call"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 6,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("truncated native arguments should trigger safe retry");
+
+ assert_eq!(result, "done after native retry");
+ assert_eq!(
+ invocations.load(Ordering::SeqCst),
+ 1,
+ "only the repaired native tool call should execute"
+ );
+ assert!(
+ history.iter().any(|msg| {
+ msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_good\"")
+ }),
+ "tool history should include only the repaired tool_call_id"
+ );
+ assert!(
+ history.iter().all(|msg| {
+ !(msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_bad\""))
+ }),
+ "invalid truncated native call must not execute"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_ignores_text_fallback_when_native_tool_args_are_truncated_json() {
+ let provider = ScriptedProvider::from_scripted_responses(vec![
+ ChatResponse {
+ text: Some(
+ r#"
+{"name":"count_tool","arguments":{"value":"from_text_fallback"}}
+ "#
+ .to_string(),
+ ),
+ tool_calls: vec![ToolCall {
+ id: "call_bad".to_string(),
+ name: "count_tool".to_string(),
+ arguments: "{\"value\":\"truncated\"".to_string(),
+ }],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![ToolCall {
+ id: "call_good".to_string(),
+ name: "count_tool".to_string(),
+ arguments: "{\"value\":\"from_native_fixed\"}".to_string(),
+ }],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::ToolCall),
+ raw_stop_reason: Some("tool_calls".to_string()),
+ },
+ ChatResponse {
+ text: Some("done after safe retry".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::EndTurn),
+ raw_stop_reason: Some("stop".to_string()),
+ },
+ ])
+ .with_native_tool_support();
+
+ let invocations = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(CountingTool::new(
+ "count_tool",
+ Arc::clone(&invocations),
+ ))];
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run native call"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 6,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("invalid native args should force retry without text fallback execution");
+
+ assert_eq!(result, "done after safe retry");
+ assert_eq!(
+ invocations.load(Ordering::SeqCst),
+ 1,
+ "only repaired native call should execute after retry"
+ );
+ assert!(
+ history
+ .iter()
+ .all(|msg| !msg.content.contains("counted:from_text_fallback")),
+ "text fallback tool call must not execute when native JSON args are invalid"
+ );
+ assert!(
+ history
+ .iter()
+ .any(|msg| msg.content.contains("counted:from_native_fixed")),
+ "repaired native call should execute after retry"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_executes_valid_native_tool_call_with_max_tokens_stop_reason() {
+ let provider = ScriptedProvider::from_scripted_responses(vec![
+ ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![ToolCall {
+ id: "call_valid".to_string(),
+ name: "count_tool".to_string(),
+ arguments: "{\"value\":\"from_valid_native\"}".to_string(),
+ }],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some("done after valid native tool".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::EndTurn),
+ raw_stop_reason: Some("stop".to_string()),
+ },
+ ])
+ .with_native_tool_support();
+
+ let invocations = Arc::new(AtomicUsize::new(0));
+ let tools_registry: Vec> = vec![Box::new(CountingTool::new(
+ "count_tool",
+ Arc::clone(&invocations),
+ ))];
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run native call"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 6,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("valid native tool calls must execute even when stop_reason is max_tokens");
+
+ assert_eq!(result, "done after valid native tool");
+ assert_eq!(
+ invocations.load(Ordering::SeqCst),
+ 1,
+ "valid native tool call should execute exactly once"
+ );
+ assert!(
+ history.iter().any(|msg| {
+ msg.role == "tool" && msg.content.contains("\"tool_call_id\":\"call_valid\"")
+ }),
+ "tool history should preserve valid native tool_call_id"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_continues_when_stop_reason_is_max_tokens() {
+ let provider = ScriptedProvider::from_scripted_responses(vec![
+ ChatResponse {
+ text: Some("part 1 ".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some("part 2".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::EndTurn),
+ raw_stop_reason: Some("stop".to_string()),
+ },
+ ]);
+
+ let tools_registry: Vec> = Vec::new();
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("continue this"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("max-token continuation should complete");
+
+ assert_eq!(result, "part 1 part 2");
+ assert!(
+ !result.contains("Response may be truncated"),
+ "continuation should not emit truncation notice when it ends cleanly"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_appends_notice_when_continuation_budget_exhausts() {
+ let provider = ScriptedProvider::from_scripted_responses(vec![
+ ChatResponse {
+ text: Some("A".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some("B".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some("C".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some("D".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ]);
+
+ let tools_registry: Vec> = Vec::new();
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("long output"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("continuation should degrade to partial output");
+
+ assert!(result.starts_with("ABCD"));
+ assert!(
+ result.contains("Response may be truncated due to continuation limits"),
+ "result should include truncation notice when continuation cap is hit"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_clamps_continuation_output_to_hard_cap() {
+ let oversized_chunk = "B".repeat(MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS);
+ let provider = ScriptedProvider::from_scripted_responses(vec![
+ ChatResponse {
+ text: Some("A".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::MaxTokens),
+ raw_stop_reason: Some("length".to_string()),
+ },
+ ChatResponse {
+ text: Some(oversized_chunk),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: Some(NormalizedStopReason::EndTurn),
+ raw_stop_reason: Some("stop".to_string()),
+ },
+ ]);
+
+ let tools_registry: Vec> = Vec::new();
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("long output"),
+ ];
+ let observer = NoopObserver;
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ None,
+ &[],
+ )
+ .await
+ .expect("continuation should clamp oversized merge");
+
+ assert!(
+ result.ends_with(MAX_TOKENS_CONTINUATION_NOTICE),
+ "hard-cap truncation should append continuation notice"
+ );
+ let capped_output = result
+ .strip_suffix(MAX_TOKENS_CONTINUATION_NOTICE)
+ .expect("result should end with continuation notice");
+ assert_eq!(
+ capped_output.chars().count(),
+ MAX_TOKENS_CONTINUATION_MAX_OUTPUT_CHARS
+ );
+ assert!(
+ capped_output.starts_with('A'),
+ "capped output should preserve earlier text before continuation chunk"
+ );
+ }
+
+ #[tokio::test]
+ async fn run_tool_call_loop_preserves_failed_tool_error_for_after_hook() {
+ let provider = ScriptedProvider::from_text_responses(vec![
+ r#"
+{"name":"failing_tool","arguments":{}}
+ "#,
+ "done",
+ ]);
+ let tools_registry: Vec> = vec![Box::new(FailingTool)];
+ let observer = NoopObserver;
+ let mut history = vec![
+ ChatMessage::system("test-system"),
+ ChatMessage::user("run failing tool"),
+ ];
+
+ let seen_errors = Arc::new(Mutex::new(Vec::new()));
+ let mut hooks = crate::hooks::HookRunner::new();
+ hooks.register(Box::new(ErrorCaptureHook {
+ seen_errors: Arc::clone(&seen_errors),
+ }));
+
+ let result = run_tool_call_loop(
+ &provider,
+ &mut history,
+ &tools_registry,
+ &observer,
+ "mock-provider",
+ "mock-model",
+ 0.0,
+ true,
+ None,
+ "cli",
+ &crate::config::MultimodalConfig::default(),
+ 4,
+ None,
+ None,
+ Some(&hooks),
+ &[],
+ )
+ .await
+ .expect("loop should complete");
+
+ assert_eq!(result, "done");
+ let recorded = seen_errors
+ .lock()
+ .expect("hook error buffer lock should be valid");
+ assert_eq!(recorded.len(), 1);
+ assert_eq!(recorded[0].as_deref(), Some("boom"));
+ }
+
+ #[test]
+ fn merge_continuation_text_deduplicates_partial_overlap() {
+ let merged = merge_continuation_text("The result is wor", "world.");
+ assert_eq!(merged, "The result is world.");
+ }
+
+ #[test]
+ fn merge_continuation_text_handles_unicode_overlap() {
+ let merged = merge_continuation_text("你好世界", "世界和平");
+ assert_eq!(merged, "你好世界和平");
+ }
+
#[test]
fn parse_tool_calls_extracts_single_call() {
let response = r#"Let me check that.
@@ -4925,14 +6996,30 @@ Done."#;
arguments: "ls -la".to_string(),
}];
let parsed = parse_structured_tool_calls(&calls);
- assert_eq!(parsed.len(), 1);
- assert_eq!(parsed[0].name, "shell");
+ assert_eq!(parsed.invalid_json_arguments, 0);
+ assert_eq!(parsed.calls.len(), 1);
+ assert_eq!(parsed.calls[0].name, "shell");
assert_eq!(
- parsed[0].arguments.get("command").and_then(|v| v.as_str()),
+ parsed.calls[0]
+ .arguments
+ .get("command")
+ .and_then(|v| v.as_str()),
Some("ls -la")
);
}
+ #[test]
+ fn parse_structured_tool_calls_skips_truncated_json_payloads() {
+ let calls = vec![ToolCall {
+ id: "call_bad".to_string(),
+ name: "count_tool".to_string(),
+ arguments: "{\"value\":\"unterminated\"".to_string(),
+ }];
+ let parsed = parse_structured_tool_calls(&calls);
+ assert_eq!(parsed.calls.len(), 0);
+ assert_eq!(parsed.invalid_json_arguments, 1);
+ }
+
// ═══════════════════════════════════════════════════════════════════════
// GLM-Style Tool Call Parsing
// ═══════════════════════════════════════════════════════════════════════
@@ -5502,4 +7589,32 @@ Let me check the result."#;
assert_eq!(parsed["content"].as_str(), Some("answer"));
assert!(parsed.get("reasoning_content").is_none());
}
+
+ #[test]
+ fn progress_mode_gates_work_as_expected() {
+ assert!(should_emit_verbose_progress(ProgressMode::Verbose));
+ assert!(!should_emit_verbose_progress(ProgressMode::Compact));
+ assert!(!should_emit_verbose_progress(ProgressMode::Off));
+
+ assert!(should_emit_tool_progress(ProgressMode::Verbose));
+ assert!(should_emit_tool_progress(ProgressMode::Compact));
+ assert!(!should_emit_tool_progress(ProgressMode::Off));
+ }
+
+ #[test]
+ fn progress_tracker_renders_in_place_block() {
+ let mut tracker = ProgressTracker::default();
+ let first = tracker.add("shell", "ls -la");
+ let second = tracker.add("web_search", "rust async test");
+ let started = tracker.render_delta();
+ assert!(started.starts_with(DRAFT_PROGRESS_BLOCK_SENTINEL));
+ assert!(started.contains("⏳ shell: ls -la"));
+ assert!(started.contains("⏳ web_search: rust async test"));
+
+ tracker.complete(first, true, 2);
+ tracker.complete(second, false, 1);
+ let completed = tracker.render_delta();
+ assert!(completed.contains("✅ shell (2s)"));
+ assert!(completed.contains("❌ web_search (1s)"));
+ }
}
diff --git a/src/agent/loop_/context.rs b/src/agent/loop_/context.rs
index cc2564619..668ea0d18 100644
--- a/src/agent/loop_/context.rs
+++ b/src/agent/loop_/context.rs
@@ -1,9 +1,29 @@
-use crate::memory::{self, Memory};
+use crate::memory::{self, decay, Memory, MemoryCategory};
use std::fmt::Write;
+/// Default half-life (days) for time decay in context building.
+const CONTEXT_DECAY_HALF_LIFE_DAYS: f64 = 7.0;
+
+/// Score boost applied to `Core` category memories so durable facts and
+/// preferences surface even when keyword/semantic similarity is moderate.
+const CORE_CATEGORY_SCORE_BOOST: f64 = 0.3;
+
+/// Maximum number of memory entries included in the context preamble.
+const CONTEXT_ENTRY_LIMIT: usize = 5;
+
+/// Over-fetch factor: retrieve more candidates than the output limit so
+/// that Core boost and re-ranking can select the best subset.
+const RECALL_OVER_FETCH_FACTOR: usize = 2;
+
/// Build context preamble by searching memory for relevant entries.
/// Entries with a hybrid score below `min_relevance_score` are dropped to
/// prevent unrelated memories from bleeding into the conversation.
+///
+/// Core memories are exempt from time decay (evergreen).
+///
+/// `Core` category memories receive a score boost so that durable facts,
+/// preferences, and project rules are more likely to appear in context
+/// even when semantic similarity to the current message is moderate.
pub(super) async fn build_context(
mem: &dyn Memory,
user_msg: &str,
@@ -12,29 +32,41 @@ pub(super) async fn build_context(
) -> String {
let mut context = String::new();
- // Pull relevant memories for this message
- if let Ok(entries) = mem.recall(user_msg, 5, session_id).await {
- let relevant: Vec<_> = entries
+ // Over-fetch so Core-boosted entries can compete fairly after re-ranking.
+ let fetch_limit = CONTEXT_ENTRY_LIMIT * RECALL_OVER_FETCH_FACTOR;
+ if let Ok(mut entries) = mem.recall(user_msg, fetch_limit, session_id).await {
+ // Apply time decay: older non-Core memories score lower.
+ decay::apply_time_decay(&mut entries, CONTEXT_DECAY_HALF_LIFE_DAYS);
+
+ // Apply Core category boost and filter by minimum relevance.
+ let mut scored: Vec<_> = entries
.iter()
- .filter(|e| match e.score {
- Some(score) => score >= min_relevance_score,
- None => true,
+ .filter(|e| !memory::is_assistant_autosave_key(&e.key))
+ .filter_map(|e| {
+ let base = e.score.unwrap_or(min_relevance_score);
+ let boosted = if e.category == MemoryCategory::Core {
+ (base + CORE_CATEGORY_SCORE_BOOST).min(1.0)
+ } else {
+ base
+ };
+ if boosted >= min_relevance_score {
+ Some((e, boosted))
+ } else {
+ None
+ }
})
.collect();
- if !relevant.is_empty() {
+ // Sort by boosted score descending, then truncate to output limit.
+ scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
+ scored.truncate(CONTEXT_ENTRY_LIMIT);
+
+ if !scored.is_empty() {
context.push_str("[Memory context]\n");
- for entry in &relevant {
- if memory::is_assistant_autosave_key(&entry.key) {
- continue;
- }
+ for (entry, _) in &scored {
let _ = writeln!(context, "- {}: {}", entry.key, entry.content);
}
- if context == "[Memory context]\n" {
- context.clear();
- } else {
- context.push('\n');
- }
+ context.push('\n');
}
}
@@ -80,3 +112,135 @@ pub(super) fn build_hardware_context(
context.push('\n');
context
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::memory::{Memory, MemoryCategory, MemoryEntry};
+ use async_trait::async_trait;
+ use std::sync::Arc;
+
+ struct MockMemory {
+ entries: Arc>,
+ }
+
+ #[async_trait]
+ impl Memory for MockMemory {
+ async fn store(
+ &self,
+ _key: &str,
+ _content: &str,
+ _category: MemoryCategory,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+
+ async fn recall(
+ &self,
+ _query: &str,
+ _limit: usize,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(self.entries.as_ref().clone())
+ }
+
+ async fn get(&self, _key: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+
+ async fn list(
+ &self,
+ _category: Option<&MemoryCategory>,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+
+ async fn forget(&self, _key: &str) -> anyhow::Result {
+ Ok(true)
+ }
+
+ async fn count(&self) -> anyhow::Result {
+ Ok(self.entries.len())
+ }
+
+ async fn health_check(&self) -> bool {
+ true
+ }
+
+ fn name(&self) -> &str {
+ "mock-memory"
+ }
+ }
+
+ #[tokio::test]
+ async fn build_context_promotes_core_entries_with_score_boost() {
+ let memory = MockMemory {
+ entries: Arc::new(vec![
+ MemoryEntry {
+ id: "1".into(),
+ key: "conv_note".into(),
+ content: "small talk".into(),
+ category: MemoryCategory::Conversation,
+ timestamp: "now".into(),
+ session_id: None,
+ score: Some(0.6),
+ },
+ MemoryEntry {
+ id: "2".into(),
+ key: "core_rule".into(),
+ content: "always provide tests".into(),
+ category: MemoryCategory::Core,
+ timestamp: "now".into(),
+ session_id: None,
+ score: Some(0.2),
+ },
+ MemoryEntry {
+ id: "3".into(),
+ key: "conv_low".into(),
+ content: "irrelevant".into(),
+ category: MemoryCategory::Conversation,
+ timestamp: "now".into(),
+ session_id: None,
+ score: Some(0.1),
+ },
+ ]),
+ };
+
+ let context = build_context(&memory, "test query", 0.4, None).await;
+ assert!(
+ context.contains("core_rule"),
+ "expected core boost to include core_rule"
+ );
+ assert!(
+ !context.contains("conv_low"),
+ "low-score non-core should be filtered"
+ );
+ }
+
+ #[tokio::test]
+ async fn build_context_keeps_output_limit_at_five_entries() {
+ let entries = (0..8)
+ .map(|idx| MemoryEntry {
+ id: idx.to_string(),
+ key: format!("k{idx}"),
+ content: format!("v{idx}"),
+ category: MemoryCategory::Conversation,
+ timestamp: "now".into(),
+ session_id: None,
+ score: Some(0.9 - (idx as f64 * 0.01)),
+ })
+ .collect::>();
+ let memory = MockMemory {
+ entries: Arc::new(entries),
+ };
+
+ let context = build_context(&memory, "limit", 0.0, None).await;
+ let listed = context
+ .lines()
+ .filter(|line| line.starts_with("- "))
+ .count();
+ assert_eq!(listed, 5, "context output limit should remain 5 entries");
+ }
+}
diff --git a/src/agent/loop_/detection.rs b/src/agent/loop_/detection.rs
index b0abca9b0..f781968fb 100644
--- a/src/agent/loop_/detection.rs
+++ b/src/agent/loop_/detection.rs
@@ -396,15 +396,15 @@ mod tests {
// 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);
diff --git a/src/agent/loop_/execution.rs b/src/agent/loop_/execution.rs
index e672df46d..ddde1bab7 100644
--- a/src/agent/loop_/execution.rs
+++ b/src/agent/loop_/execution.rs
@@ -107,7 +107,10 @@ pub(super) fn should_execute_tools_in_parallel(
}
if let Some(mgr) = approval {
- if tool_calls.iter().any(|call| mgr.needs_approval(&call.name)) {
+ if tool_calls
+ .iter()
+ .any(|call| mgr.needs_approval_for_call(&call.name, &call.arguments))
+ {
// Approval-gated calls must keep sequential handling so the caller can
// enforce CLI prompt/deny policy consistently.
return false;
diff --git a/src/agent/loop_/history.rs b/src/agent/loop_/history.rs
index 3fdfe33f0..41b0bd1f4 100644
--- a/src/agent/loop_/history.rs
+++ b/src/agent/loop_/history.rs
@@ -1,3 +1,4 @@
+use crate::memory::{Memory, MemoryCategory};
use crate::providers::{ChatMessage, Provider};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
@@ -12,6 +13,40 @@ const COMPACTION_MAX_SOURCE_CHARS: usize = 12_000;
/// Max characters retained in stored compaction summary.
const COMPACTION_MAX_SUMMARY_CHARS: usize = 2_000;
+/// Safety cap for durable facts extracted during pre-compaction flush.
+const COMPACTION_MAX_FLUSH_FACTS: usize = 8;
+
+/// Number of conversation turns between automatic fact extractions.
+const EXTRACT_TURN_INTERVAL: usize = 5;
+
+/// Minimum combined character count (user + assistant) to trigger extraction.
+const EXTRACT_MIN_CHARS: usize = 200;
+
+/// Safety cap for fact-extraction transcript sent to the LLM.
+const EXTRACT_MAX_SOURCE_CHARS: usize = 12_000;
+
+/// Maximum characters for the "already known facts" section injected into
+/// the extraction prompt. Keeps token cost bounded when recall returns
+/// long entries.
+const KNOWN_SECTION_MAX_CHARS: usize = 2_000;
+
+/// Maximum length (in chars) for a normalized fact key.
+const FACT_KEY_MAX_LEN: usize = 64;
+
+/// Substrings that indicate a fact is purely a secret shell after redaction.
+const SECRET_SHELL_PATTERNS: &[&str] = &[
+ "api key",
+ "api_key",
+ "token",
+ "password",
+ "secret",
+ "credential",
+ "access key",
+ "access_key",
+ "private key",
+ "private_key",
+];
+
/// Trim conversation history to prevent unbounded growth.
/// Preserves the system prompt (first message if role=system) and the most recent messages.
pub(super) fn trim_history(history: &mut Vec, max_history: usize) {
@@ -61,12 +96,20 @@ pub(super) fn apply_compaction_summary(
history.splice(start..compact_end, std::iter::once(summary_msg));
}
+/// Returns `(compacted, flush_ok)`:
+/// - `compacted`: whether history was actually compacted
+/// - `flush_ok`: whether the pre-compaction `flush_durable_facts` succeeded
+/// (always `true` when `post_turn_active` or compaction didn't happen)
pub(super) async fn auto_compact_history(
history: &mut Vec,
provider: &dyn Provider,
model: &str,
max_history: usize,
-) -> Result {
+ hooks: Option<&crate::hooks::HookRunner>,
+ memory: Option<&dyn Memory>,
+ session_id: Option<&str>,
+ post_turn_active: bool,
+) -> Result<(bool, bool)> {
let has_system = history.first().map_or(false, |m| m.role == "system");
let non_system_count = if has_system {
history.len().saturating_sub(1)
@@ -75,14 +118,14 @@ pub(super) async fn auto_compact_history(
};
if non_system_count <= max_history {
- return Ok(false);
+ return Ok((false, true));
}
let start = if has_system { 1 } else { 0 };
let keep_recent = COMPACTION_KEEP_RECENT_MESSAGES.min(non_system_count);
let compact_count = non_system_count.saturating_sub(keep_recent);
if compact_count == 0 {
- return Ok(false);
+ return Ok((false, true));
}
let mut compact_end = start + compact_count;
@@ -91,8 +134,31 @@ pub(super) async fn auto_compact_history(
compact_end += 1;
}
let to_compact: Vec = history[start..compact_end].to_vec();
+ let to_compact = if let Some(hooks) = hooks {
+ match hooks.run_before_compaction(to_compact).await {
+ crate::hooks::HookResult::Continue(messages) => messages,
+ crate::hooks::HookResult::Cancel(reason) => {
+ tracing::info!(%reason, "history compaction cancelled by hook");
+ return Ok((false, true));
+ }
+ }
+ } else {
+ to_compact
+ };
let transcript = build_compaction_transcript(&to_compact);
+ // ── Pre-compaction memory flush ──────────────────────────────────
+ // Before discarding old messages, ask the LLM to extract durable
+ // facts and store them as Core memories so they survive compaction.
+ // Skip when post-turn extraction is active (it already covered these turns).
+ let flush_ok = if post_turn_active {
+ true
+ } else if let Some(mem) = memory {
+ flush_durable_facts(provider, model, &transcript, mem, session_id).await
+ } else {
+ true
+ };
+
let summarizer_system = "You are a conversation compaction engine. Summarize older chat history into concise context for future turns. Preserve: user preferences, commitments, decisions, unresolved tasks, key facts. Omit: filler, repeated chit-chat, verbose tool logs. Output plain text bullet points only.";
let summarizer_user = format!(
@@ -109,9 +175,412 @@ pub(super) async fn auto_compact_history(
});
let summary = truncate_with_ellipsis(&summary_raw, COMPACTION_MAX_SUMMARY_CHARS);
+ let summary = if let Some(hooks) = hooks {
+ match hooks.run_after_compaction(summary).await {
+ crate::hooks::HookResult::Continue(next_summary) => next_summary,
+ crate::hooks::HookResult::Cancel(reason) => {
+ tracing::info!(%reason, "post-compaction summary cancelled by hook");
+ return Ok((false, true));
+ }
+ }
+ } else {
+ summary
+ };
apply_compaction_summary(history, start, compact_end, &summary);
- Ok(true)
+ Ok((true, flush_ok))
+}
+
+/// Extract durable facts from a conversation transcript and store them as
+/// `Core` memories. Called before compaction discards old messages.
+///
+/// Best-effort: failures are logged but never block compaction.
+/// Returns `true` when facts were stored **or** the LLM confirmed
+/// there are none (`NONE` response). Returns `false` on LLM/store
+/// failures so the caller can avoid marking extraction as successful.
+async fn flush_durable_facts(
+ provider: &dyn Provider,
+ model: &str,
+ transcript: &str,
+ memory: &dyn Memory,
+ session_id: Option<&str>,
+) -> bool {
+ const FLUSH_SYSTEM: &str = "\
+You extract durable facts from a conversation that is about to be compacted. \
+Output ONLY facts worth remembering long-term — user preferences, project decisions, \
+technical constraints, commitments, or important discoveries.\n\
+\n\
+NEVER extract secrets, API keys, tokens, passwords, credentials, \
+or any sensitive authentication data. If the conversation contains \
+such data, skip it entirely.\n\
+\n\
+Output one fact per line, prefixed with a short key in brackets. \
+Example:\n\
+[preferred_language] User prefers Rust over Go\n\
+[db_choice] Project uses PostgreSQL 16\n\
+If there are no durable facts, output exactly: NONE";
+
+ let flush_user = format!(
+ "Extract durable facts from this conversation (max 8 facts):\n\n{}",
+ transcript
+ );
+
+ let response = match provider
+ .chat_with_system(Some(FLUSH_SYSTEM), &flush_user, model, 0.2)
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!("Pre-compaction memory flush failed: {e}");
+ return false;
+ }
+ };
+
+ if response.trim().eq_ignore_ascii_case("NONE") {
+ return true; // genuinely no facts
+ }
+ if response.trim().is_empty() {
+ return false; // provider returned empty — treat as failure
+ }
+
+ let mut stored = 0usize;
+ let mut parsed = 0usize;
+ let mut store_failures = 0usize;
+ for line in response.lines() {
+ if stored >= COMPACTION_MAX_FLUSH_FACTS {
+ break;
+ }
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ // Parse "[key] content" format
+ if let Some((key, content)) = parse_fact_line(line) {
+ parsed += 1;
+ // Scrub secrets from extracted content.
+ let clean = crate::providers::scrub_secret_patterns(content);
+ if should_skip_redacted_fact(&clean, content) {
+ tracing::info!(
+ "Skipped compaction fact '{key}': only secret shell remains after redaction"
+ );
+ continue;
+ }
+ let norm_key = normalize_fact_key(key);
+ if norm_key.is_empty() {
+ continue;
+ }
+ let prefixed_key = format!("auto_{norm_key}");
+ if let Err(e) = memory
+ .store(&prefixed_key, &clean, MemoryCategory::Core, session_id)
+ .await
+ {
+ tracing::warn!("Failed to store compaction fact '{prefixed_key}': {e}");
+ store_failures += 1;
+ } else {
+ stored += 1;
+ }
+ }
+ }
+ if stored > 0 {
+ tracing::info!("Pre-compaction flush: stored {stored} durable fact(s) to Core memory");
+ }
+ // Success when at least one fact was parsed and no store failures
+ // occurred, OR all parsed facts were intentionally skipped.
+ // Unparseable output (parsed == 0) is treated as failure.
+ parsed > 0 && store_failures == 0
+}
+
+/// Parse a `[key] content` line from the fact extraction output.
+fn parse_fact_line(line: &str) -> Option<(&str, &str)> {
+ let line = line.trim_start_matches(|c: char| c == '-' || c.is_whitespace());
+ let rest = line.strip_prefix('[')?;
+ let close = rest.find(']')?;
+ let key = rest[..close].trim();
+ let content = rest[close + 1..].trim();
+ if key.is_empty() || content.is_empty() {
+ return None;
+ }
+ Some((key, content))
+}
+
+/// Normalize a fact key to a consistent `snake_case` form with length cap.
+///
+/// - Replaces whitespace/hyphens with underscores
+/// - Lowercases
+/// - Strips non-alphanumeric (except `_`)
+/// - Collapses repeated underscores
+/// - Truncates to [`FACT_KEY_MAX_LEN`]
+fn normalize_fact_key(raw: &str) -> String {
+ let mut key: String = raw
+ .chars()
+ .map(|c| {
+ if c.is_alphanumeric() {
+ c.to_ascii_lowercase()
+ } else {
+ '_'
+ }
+ })
+ .collect();
+ // Collapse repeated underscores.
+ while key.contains("__") {
+ key = key.replace("__", "_");
+ }
+ let key = key.trim_matches('_');
+ if key.chars().count() > FACT_KEY_MAX_LEN {
+ key.chars().take(FACT_KEY_MAX_LEN).collect()
+ } else {
+ key.to_string()
+ }
+}
+
+// ── Post-turn fact extraction ───────────────────────────────────────
+
+/// Accumulates conversation turns for periodic fact extraction.
+///
+/// Decoupled from `history` so tool/summary messages do not affect
+/// the extraction window.
+pub(crate) struct TurnBuffer {
+ turns: Vec<(String, String)>,
+ total_chars: usize,
+ last_extract_succeeded: bool,
+}
+
+/// Outcome of a single extraction attempt.
+pub(crate) struct ExtractionResult {
+ /// Number of facts successfully stored to Core memory.
+ pub stored: usize,
+ /// `true` when the LLM confirmed there are no new facts (or all parsed
+ /// facts were intentionally skipped). `false` on LLM/store failures.
+ pub no_facts: bool,
+}
+
+impl TurnBuffer {
+ pub fn new() -> Self {
+ Self {
+ turns: Vec::new(),
+ total_chars: 0,
+ last_extract_succeeded: true,
+ }
+ }
+
+ /// Record a completed conversation turn.
+ pub fn push(&mut self, user_msg: &str, assistant_resp: &str) {
+ self.total_chars += user_msg.chars().count() + assistant_resp.chars().count();
+ self.turns
+ .push((user_msg.to_string(), assistant_resp.to_string()));
+ }
+
+ /// Whether the buffer has accumulated enough turns and content to
+ /// justify an extraction call.
+ pub fn should_extract(&self) -> bool {
+ self.turns.len() >= EXTRACT_TURN_INTERVAL && self.total_chars >= EXTRACT_MIN_CHARS
+ }
+
+ /// Drain all buffered turns and return them for extraction.
+ /// Resets character counter; `last_extract_succeeded` is cleared
+ /// until the caller confirms success via [`mark_extract_success`].
+ pub fn drain_for_extraction(&mut self) -> Vec<(String, String)> {
+ self.total_chars = 0;
+ self.last_extract_succeeded = false;
+ std::mem::take(&mut self.turns)
+ }
+
+ /// Mark the most recent extraction as successful.
+ pub fn mark_extract_success(&mut self) {
+ self.last_extract_succeeded = true;
+ }
+
+ /// Whether there are buffered turns that have not been extracted.
+ pub fn is_empty(&self) -> bool {
+ self.turns.is_empty()
+ }
+
+ /// Whether compaction should fall back to its own `flush_durable_facts`.
+ /// This returns `true` when un-extracted turns remain **or** the last
+ /// extraction failed (so durable facts may have been lost).
+ pub fn needs_compaction_fallback(&self) -> bool {
+ !self.turns.is_empty() || !self.last_extract_succeeded
+ }
+}
+
+/// Extract durable facts from recent conversation turns and store them
+/// as `Core` memories.
+///
+/// Best-effort: failures are logged but never block the caller.
+///
+/// This is the unified extraction entry-point used by all agent entry
+/// points (single-message, interactive, channel, `Agent` struct).
+pub(crate) async fn extract_facts_from_turns(
+ provider: &dyn Provider,
+ model: &str,
+ turns: &[(String, String)],
+ memory: &dyn Memory,
+ session_id: Option<&str>,
+) -> ExtractionResult {
+ let empty = ExtractionResult {
+ stored: 0,
+ no_facts: true,
+ };
+
+ if turns.is_empty() {
+ return empty;
+ }
+
+ // Build transcript from buffered turns.
+ let mut transcript = String::new();
+ for (user, assistant) in turns {
+ let _ = writeln!(transcript, "USER: {}", user.trim());
+ let _ = writeln!(transcript, "ASSISTANT: {}", assistant.trim());
+ transcript.push('\n');
+ }
+
+ let total_chars: usize = turns
+ .iter()
+ .map(|(u, a)| u.chars().count() + a.chars().count())
+ .sum();
+ if total_chars < EXTRACT_MIN_CHARS {
+ return empty;
+ }
+
+ // Truncate to avoid oversized LLM prompts with very long messages.
+ if transcript.chars().count() > EXTRACT_MAX_SOURCE_CHARS {
+ transcript = truncate_with_ellipsis(&transcript, EXTRACT_MAX_SOURCE_CHARS);
+ }
+
+ // Recall existing memories for dedup context.
+ let existing = memory
+ .recall(&transcript, 10, session_id)
+ .await
+ .unwrap_or_default();
+
+ let mut known_section = String::new();
+ if !existing.is_empty() {
+ known_section.push_str(
+ "\nYou already know these facts (do NOT repeat them; \
+ use the SAME key if a fact needs updating):\n",
+ );
+ for entry in &existing {
+ let line = format!("- {}: {}\n", entry.key, entry.content);
+ if known_section.chars().count() + line.chars().count() > KNOWN_SECTION_MAX_CHARS {
+ known_section.push_str("- ... (truncated)\n");
+ break;
+ }
+ known_section.push_str(&line);
+ }
+ }
+
+ let system_prompt = format!(
+ "You extract durable facts from a conversation. \
+ Output ONLY facts worth remembering long-term \u{2014} user preferences, project decisions, \
+ technical constraints, commitments, or important discoveries.\n\
+ \n\
+ NEVER extract secrets, API keys, tokens, passwords, credentials, \
+ or any sensitive authentication data. If the conversation contains \
+ such data, skip it entirely.\n\
+ {known_section}\n\
+ Output one fact per line, prefixed with a short key in brackets.\n\
+ Example:\n\
+ [preferred_language] User prefers Rust over Go\n\
+ [db_choice] Project uses PostgreSQL 16\n\
+ If there are no new durable facts, output exactly: NONE"
+ );
+
+ let user_prompt = format!(
+ "Extract durable facts from this conversation (max {} facts):\n\n{}",
+ COMPACTION_MAX_FLUSH_FACTS, transcript
+ );
+
+ let response = match provider
+ .chat_with_system(Some(&system_prompt), &user_prompt, model, 0.2)
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ tracing::warn!("Post-turn fact extraction failed: {e}");
+ return ExtractionResult {
+ stored: 0,
+ no_facts: false,
+ };
+ }
+ };
+
+ if response.trim().eq_ignore_ascii_case("NONE") {
+ return empty;
+ }
+ if response.trim().is_empty() {
+ // Provider returned empty — treat as failure so compaction
+ // fallback remains active.
+ return ExtractionResult {
+ stored: 0,
+ no_facts: false,
+ };
+ }
+
+ let mut stored = 0usize;
+ let mut parsed = 0usize;
+ let mut store_failures = 0usize;
+ for line in response.lines() {
+ if stored >= COMPACTION_MAX_FLUSH_FACTS {
+ break;
+ }
+ let line = line.trim();
+ if line.is_empty() {
+ continue;
+ }
+ if let Some((key, content)) = parse_fact_line(line) {
+ parsed += 1;
+ // Scrub secrets from extracted content.
+ let clean = crate::providers::scrub_secret_patterns(content);
+ if should_skip_redacted_fact(&clean, content) {
+ tracing::info!("Skipped fact '{key}': only secret shell remains after redaction");
+ continue;
+ }
+ let norm_key = normalize_fact_key(key);
+ if norm_key.is_empty() {
+ continue;
+ }
+ let prefixed_key = format!("auto_{norm_key}");
+ if let Err(e) = memory
+ .store(&prefixed_key, &clean, MemoryCategory::Core, session_id)
+ .await
+ {
+ tracing::warn!("Failed to store extracted fact '{prefixed_key}': {e}");
+ store_failures += 1;
+ } else {
+ stored += 1;
+ }
+ }
+ }
+ if stored > 0 {
+ tracing::info!("Post-turn extraction: stored {stored} durable fact(s) to Core memory");
+ }
+
+ // no_facts is true only when the LLM returned parseable facts that were
+ // all intentionally skipped (e.g. redacted) — NOT when store() failed.
+ // When parsed == 0 (unparseable output) or store_failures > 0 (backend
+ // errors), treat as failure so compaction fallback remains active.
+ ExtractionResult {
+ stored,
+ no_facts: parsed > 0 && stored == 0 && store_failures == 0,
+ }
+}
+
+/// Decide whether a redacted fact should be skipped.
+///
+/// A fact is skipped when scrubbing removed secrets and the remaining
+/// text is empty or consists solely of generic secret-type labels
+/// (e.g. "api key", "token").
+fn should_skip_redacted_fact(clean: &str, original: &str) -> bool {
+ // No redaction happened — always keep.
+ if clean == original {
+ return false;
+ }
+ let remainder = clean.replace("[REDACTED]", "").trim().to_lowercase();
+ let remainder = remainder.trim_matches(|c: char| c.is_ascii_punctuation() || c.is_whitespace());
+ if remainder.is_empty() {
+ return true;
+ }
+ SECRET_SHELL_PATTERNS.contains(&remainder)
}
#[cfg(test)]
@@ -146,6 +615,8 @@ mod tests {
usage: None,
reasoning_content: None,
quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
})
}
}
@@ -190,12 +661,20 @@ mod tests {
// 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");
+ let compacted = auto_compact_history(
+ &mut history,
+ &StaticSummaryProvider,
+ "test-model",
+ 21,
+ None,
+ None,
+ None,
+ false,
+ )
+ .await
+ .expect("compaction should succeed");
- assert!(compacted);
+ assert!(compacted.0);
assert_eq!(history[0].role, "assistant");
assert!(
history[0].content.contains("[Compaction summary]"),
@@ -206,4 +685,1017 @@ mod tests {
"first retained message must not be an orphan tool result"
);
}
+
+ #[test]
+ fn parse_fact_line_extracts_key_and_content() {
+ assert_eq!(
+ parse_fact_line("[preferred_language] User prefers Rust over Go"),
+ Some(("preferred_language", "User prefers Rust over Go"))
+ );
+ }
+
+ #[test]
+ fn parse_fact_line_handles_leading_dash() {
+ assert_eq!(
+ parse_fact_line("- [db_choice] Project uses PostgreSQL 16"),
+ Some(("db_choice", "Project uses PostgreSQL 16"))
+ );
+ }
+
+ #[test]
+ fn parse_fact_line_rejects_empty_key_or_content() {
+ assert_eq!(parse_fact_line("[] some content"), None);
+ assert_eq!(parse_fact_line("[key]"), None);
+ assert_eq!(parse_fact_line("[key] "), None);
+ }
+
+ #[test]
+ fn parse_fact_line_rejects_malformed_input() {
+ assert_eq!(parse_fact_line("no brackets here"), None);
+ assert_eq!(parse_fact_line(""), None);
+ assert_eq!(parse_fact_line("[unclosed bracket"), None);
+ }
+
+ #[test]
+ fn normalize_fact_key_basic() {
+ assert_eq!(
+ normalize_fact_key("preferred_language"),
+ "preferred_language"
+ );
+ assert_eq!(normalize_fact_key("DB Choice"), "db_choice");
+ assert_eq!(normalize_fact_key("my-cool-key"), "my_cool_key");
+ assert_eq!(normalize_fact_key(" spaces "), "spaces");
+ assert_eq!(normalize_fact_key("UPPER_CASE"), "upper_case");
+ }
+
+ #[test]
+ fn normalize_fact_key_collapses_underscores() {
+ assert_eq!(normalize_fact_key("a___b"), "a_b");
+ assert_eq!(normalize_fact_key("--key--"), "key");
+ }
+
+ #[test]
+ fn normalize_fact_key_truncates_long_keys() {
+ let long = "a".repeat(100);
+ let result = normalize_fact_key(&long);
+ assert_eq!(result.len(), FACT_KEY_MAX_LEN);
+ }
+
+ #[test]
+ fn normalize_fact_key_empty_on_garbage() {
+ assert_eq!(normalize_fact_key("!!!"), "");
+ assert_eq!(normalize_fact_key(""), "");
+ }
+
+ #[tokio::test]
+ async fn auto_compact_with_memory_stores_durable_facts() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+ use std::sync::{Arc, Mutex};
+
+ struct FactCapture {
+ stored: Mutex>,
+ }
+
+ #[async_trait]
+ impl Memory for FactCapture {
+ async fn store(
+ &self,
+ key: &str,
+ content: &str,
+ _category: MemoryCategory,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result<()> {
+ self.stored
+ .lock()
+ .unwrap()
+ .push((key.to_string(), content.to_string()));
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+ fn name(&self) -> &str {
+ "fact-capture"
+ }
+ }
+
+ /// Provider that returns facts for the first call (flush) and summary for the second (compaction).
+ struct FlushThenSummaryProvider {
+ call_count: Mutex,
+ }
+
+ #[async_trait]
+ impl Provider for FlushThenSummaryProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ let mut count = self.call_count.lock().unwrap();
+ *count += 1;
+ if *count == 1 {
+ // flush_durable_facts call
+ Ok("[lang] User prefers Rust\n[db] PostgreSQL 16".to_string())
+ } else {
+ // summarizer call
+ Ok("- summarized context".to_string())
+ }
+ }
+
+ async fn chat(
+ &self,
+ _request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some("- summarized context".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ let mem = Arc::new(FactCapture {
+ stored: Mutex::new(Vec::new()),
+ });
+ let provider = FlushThenSummaryProvider {
+ call_count: Mutex::new(0),
+ };
+
+ let mut history: Vec = Vec::new();
+ for i in 0..25 {
+ history.push(ChatMessage::user(format!("msg-{i}")));
+ }
+
+ let compacted = auto_compact_history(
+ &mut history,
+ &provider,
+ "test-model",
+ 21,
+ None,
+ Some(mem.as_ref()),
+ None,
+ false,
+ )
+ .await
+ .expect("compaction should succeed");
+
+ assert!(compacted.0);
+
+ let stored = mem.stored.lock().unwrap();
+ assert_eq!(stored.len(), 2, "should store 2 durable facts");
+ assert_eq!(stored[0].0, "auto_lang");
+ assert_eq!(stored[0].1, "User prefers Rust");
+ assert_eq!(stored[1].0, "auto_db");
+ assert_eq!(stored[1].1, "PostgreSQL 16");
+ }
+
+ #[tokio::test]
+ async fn auto_compact_with_memory_caps_fact_flush_at_eight_entries() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+ use std::sync::{Arc, Mutex};
+
+ struct FactCapture {
+ stored: Mutex>,
+ }
+
+ #[async_trait]
+ impl Memory for FactCapture {
+ async fn store(
+ &self,
+ key: &str,
+ content: &str,
+ _category: MemoryCategory,
+ _session_id: Option<&str>,
+ ) -> anyhow::Result<()> {
+ self.stored
+ .lock()
+ .expect("fact capture lock")
+ .push((key.to_string(), content.to_string()));
+ Ok(())
+ }
+
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+
+ async fn health_check(&self) -> bool {
+ true
+ }
+
+ fn name(&self) -> &str {
+ "fact-capture-cap"
+ }
+ }
+
+ struct FlushManyFactsProvider {
+ call_count: Mutex,
+ }
+
+ #[async_trait]
+ impl Provider for FlushManyFactsProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ let mut count = self.call_count.lock().expect("provider lock");
+ *count += 1;
+ if *count == 1 {
+ let lines = (0..12)
+ .map(|idx| format!("[k{idx}] fact-{idx}"))
+ .collect::>()
+ .join("\n");
+ Ok(lines)
+ } else {
+ Ok("- summarized context".to_string())
+ }
+ }
+
+ async fn chat(
+ &self,
+ _request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some("- summarized context".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ let mem = Arc::new(FactCapture {
+ stored: Mutex::new(Vec::new()),
+ });
+ let provider = FlushManyFactsProvider {
+ call_count: Mutex::new(0),
+ };
+ let mut history = (0..30)
+ .map(|idx| ChatMessage::user(format!("msg-{idx}")))
+ .collect::>();
+
+ let compacted = auto_compact_history(
+ &mut history,
+ &provider,
+ "test-model",
+ 21,
+ None,
+ Some(mem.as_ref()),
+ None,
+ false,
+ )
+ .await
+ .expect("compaction should succeed");
+ assert!(compacted.0);
+
+ let stored = mem.stored.lock().expect("fact capture lock");
+ assert_eq!(stored.len(), COMPACTION_MAX_FLUSH_FACTS);
+ assert_eq!(stored[0].0, "auto_k0");
+ assert_eq!(stored[7].0, "auto_k7");
+ }
+
+ // ── TurnBuffer unit tests ──────────────────────────────────────
+
+ #[test]
+ fn turn_buffer_should_extract_requires_interval_and_chars() {
+ let mut buf = TurnBuffer::new();
+ assert!(!buf.should_extract());
+
+ // Push turns with short content — interval met but chars not.
+ for i in 0..EXTRACT_TURN_INTERVAL {
+ buf.push(&format!("q{i}"), "a");
+ }
+ assert!(!buf.should_extract());
+
+ // Reset and push with enough chars.
+ let mut buf2 = TurnBuffer::new();
+ let long_msg = "x".repeat(EXTRACT_MIN_CHARS);
+ for _ in 0..EXTRACT_TURN_INTERVAL {
+ buf2.push(&long_msg, "reply");
+ }
+ assert!(buf2.should_extract());
+ }
+
+ #[test]
+ fn turn_buffer_drain_clears_and_marks_pending() {
+ let mut buf = TurnBuffer::new();
+ buf.push("hello", "world");
+ assert!(!buf.is_empty());
+
+ let turns = buf.drain_for_extraction();
+ assert_eq!(turns.len(), 1);
+ assert!(buf.is_empty());
+ assert!(buf.needs_compaction_fallback()); // last_extract_succeeded = false after drain
+ }
+
+ #[test]
+ fn turn_buffer_mark_success_clears_fallback() {
+ let mut buf = TurnBuffer::new();
+ buf.push("q", "a");
+ let _ = buf.drain_for_extraction();
+ assert!(buf.needs_compaction_fallback());
+
+ buf.mark_extract_success();
+ assert!(!buf.needs_compaction_fallback());
+ }
+
+ #[test]
+ fn turn_buffer_needs_fallback_when_not_empty() {
+ let mut buf = TurnBuffer::new();
+ assert!(!buf.needs_compaction_fallback());
+
+ buf.push("q", "a");
+ assert!(buf.needs_compaction_fallback());
+ }
+
+ #[test]
+ fn turn_buffer_counts_chars_not_bytes() {
+ let mut buf = TurnBuffer::new();
+ // Each CJK char is 1 char but 3 bytes.
+ let cjk = "你".repeat(EXTRACT_MIN_CHARS);
+ for _ in 0..EXTRACT_TURN_INTERVAL {
+ buf.push(&cjk, "ok");
+ }
+ assert!(buf.should_extract());
+ }
+
+ // ── should_skip_redacted_fact unit tests ───────────────────────
+
+ #[test]
+ fn skip_redacted_no_redaction_keeps_fact() {
+ assert!(!should_skip_redacted_fact(
+ "User prefers Rust",
+ "User prefers Rust"
+ ));
+ }
+
+ #[test]
+ fn skip_redacted_empty_remainder_skips() {
+ assert!(should_skip_redacted_fact("[REDACTED]", "sk-12345secret"));
+ }
+
+ #[test]
+ fn skip_redacted_secret_shell_skips() {
+ assert!(should_skip_redacted_fact(
+ "api key [REDACTED]",
+ "api key sk-12345secret"
+ ));
+ assert!(should_skip_redacted_fact(
+ "token: [REDACTED]",
+ "token: abc123xyz"
+ ));
+ }
+
+ #[test]
+ fn skip_redacted_meaningful_remainder_keeps() {
+ assert!(!should_skip_redacted_fact(
+ "User's deployment uses [REDACTED] for auth with PostgreSQL 16",
+ "User's deployment uses sk-secret for auth with PostgreSQL 16"
+ ));
+ }
+
+ // ── extract_facts_from_turns integration tests ─────────────────
+
+ #[tokio::test]
+ async fn extract_facts_stores_with_auto_prefix_and_core_category() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+ use std::sync::{Arc, Mutex};
+
+ #[allow(clippy::type_complexity)]
+ struct CaptureMem {
+ stored: Mutex)>>,
+ }
+
+ #[async_trait]
+ impl Memory for CaptureMem {
+ async fn store(
+ &self,
+ key: &str,
+ content: &str,
+ category: MemoryCategory,
+ session_id: Option<&str>,
+ ) -> anyhow::Result<()> {
+ self.stored.lock().unwrap().push((
+ key.to_string(),
+ content.to_string(),
+ category,
+ session_id.map(String::from),
+ ));
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+ fn name(&self) -> &str {
+ "capture"
+ }
+ }
+
+ struct FactExtractProvider;
+
+ #[async_trait]
+ impl Provider for FactExtractProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ Ok("[lang] User prefers Rust\n[db] PostgreSQL 16".to_string())
+ }
+ async fn chat(
+ &self,
+ _request: ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ let mem = Arc::new(CaptureMem {
+ stored: Mutex::new(Vec::new()),
+ });
+ // Build turns with enough chars to exceed EXTRACT_MIN_CHARS.
+ let long_msg = "x".repeat(EXTRACT_MIN_CHARS);
+ let turns = vec![(long_msg, "assistant reply".to_string())];
+
+ let result = extract_facts_from_turns(
+ &FactExtractProvider,
+ "test-model",
+ &turns,
+ mem.as_ref(),
+ Some("session-42"),
+ )
+ .await;
+
+ assert_eq!(result.stored, 2);
+ assert!(!result.no_facts);
+
+ let stored = mem.stored.lock().unwrap();
+ assert_eq!(stored[0].0, "auto_lang");
+ assert_eq!(stored[0].1, "User prefers Rust");
+ assert!(matches!(stored[0].2, MemoryCategory::Core));
+ assert_eq!(stored[0].3, Some("session-42".to_string()));
+ assert_eq!(stored[1].0, "auto_db");
+ }
+
+ #[tokio::test]
+ async fn extract_facts_returns_no_facts_on_none_response() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+
+ struct NoopMem;
+
+ #[async_trait]
+ impl Memory for NoopMem {
+ async fn store(
+ &self,
+ _k: &str,
+ _c: &str,
+ _cat: MemoryCategory,
+ _s: Option<&str>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+ fn name(&self) -> &str {
+ "noop"
+ }
+ }
+
+ struct NoneProvider;
+
+ #[async_trait]
+ impl Provider for NoneProvider {
+ async fn chat_with_system(
+ &self,
+ _sp: Option<&str>,
+ _m: &str,
+ _model: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok("NONE".to_string())
+ }
+ async fn chat(
+ &self,
+ _r: ChatRequest<'_>,
+ _m: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ let long_msg = "x".repeat(EXTRACT_MIN_CHARS);
+ let turns = vec![(long_msg, "resp".to_string())];
+ let result = extract_facts_from_turns(&NoneProvider, "model", &turns, &NoopMem, None).await;
+
+ assert_eq!(result.stored, 0);
+ assert!(result.no_facts);
+ }
+
+ #[tokio::test]
+ async fn extract_facts_below_min_chars_returns_empty() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+
+ struct NoopMem;
+
+ #[async_trait]
+ impl Memory for NoopMem {
+ async fn store(
+ &self,
+ _k: &str,
+ _c: &str,
+ _cat: MemoryCategory,
+ _s: Option<&str>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+ fn name(&self) -> &str {
+ "noop"
+ }
+ }
+
+ let turns = vec![("hi".to_string(), "hey".to_string())];
+ let result =
+ extract_facts_from_turns(&StaticSummaryProvider, "model", &turns, &NoopMem, None).await;
+
+ assert_eq!(result.stored, 0);
+ assert!(result.no_facts);
+ }
+
+ #[tokio::test]
+ async fn extract_facts_unparseable_response_marks_no_facts_false() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+
+ struct NoopMem;
+
+ #[async_trait]
+ impl Memory for NoopMem {
+ async fn store(
+ &self,
+ _k: &str,
+ _c: &str,
+ _cat: MemoryCategory,
+ _s: Option<&str>,
+ ) -> anyhow::Result<()> {
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+ fn name(&self) -> &str {
+ "noop"
+ }
+ }
+
+ /// Provider that returns unparseable garbage (no `[key] value` format).
+ struct GarbageProvider;
+
+ #[async_trait]
+ impl Provider for GarbageProvider {
+ async fn chat_with_system(
+ &self,
+ _sp: Option<&str>,
+ _m: &str,
+ _model: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok("This is just random text without any facts.".to_string())
+ }
+ async fn chat(
+ &self,
+ _r: ChatRequest<'_>,
+ _m: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ let long_msg = "x".repeat(EXTRACT_MIN_CHARS);
+ let turns = vec![(long_msg, "resp".to_string())];
+ let result =
+ extract_facts_from_turns(&GarbageProvider, "model", &turns, &NoopMem, None).await;
+
+ assert_eq!(result.stored, 0);
+ // Unparseable output should NOT be treated as "no facts" — compaction
+ // fallback should remain active.
+ assert!(
+ !result.no_facts,
+ "unparseable LLM response must not mark extraction as successful"
+ );
+ }
+
+ #[tokio::test]
+ async fn extract_facts_store_failure_marks_no_facts_false() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+
+ /// Memory backend that always fails on store.
+ struct FailMem;
+
+ #[async_trait]
+ impl Memory for FailMem {
+ async fn store(
+ &self,
+ _k: &str,
+ _c: &str,
+ _cat: MemoryCategory,
+ _s: Option<&str>,
+ ) -> anyhow::Result<()> {
+ anyhow::bail!("disk full")
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ false
+ }
+ fn name(&self) -> &str {
+ "fail"
+ }
+ }
+
+ /// Provider that returns valid parseable facts.
+ struct FactProvider;
+
+ #[async_trait]
+ impl Provider for FactProvider {
+ async fn chat_with_system(
+ &self,
+ _sp: Option<&str>,
+ _m: &str,
+ _model: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok("[lang] Rust\n[db] PostgreSQL".to_string())
+ }
+ async fn chat(
+ &self,
+ _r: ChatRequest<'_>,
+ _m: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some(String::new()),
+ tool_calls: vec![],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ let long_msg = "x".repeat(EXTRACT_MIN_CHARS);
+ let turns = vec![(long_msg, "resp".to_string())];
+ let result = extract_facts_from_turns(&FactProvider, "model", &turns, &FailMem, None).await;
+
+ assert_eq!(result.stored, 0);
+ assert!(
+ !result.no_facts,
+ "store failures must not mark extraction as successful"
+ );
+ }
+
+ #[tokio::test]
+ async fn compaction_skips_flush_when_post_turn_active() {
+ use crate::memory::{MemoryCategory, MemoryEntry};
+ use std::sync::{Arc, Mutex};
+
+ struct FactCapture {
+ stored: Mutex>,
+ }
+
+ #[async_trait]
+ impl Memory for FactCapture {
+ async fn store(
+ &self,
+ key: &str,
+ content: &str,
+ _cat: MemoryCategory,
+ _s: Option<&str>,
+ ) -> anyhow::Result<()> {
+ self.stored
+ .lock()
+ .unwrap()
+ .push((key.to_string(), content.to_string()));
+ Ok(())
+ }
+ async fn recall(
+ &self,
+ _q: &str,
+ _l: usize,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn get(&self, _k: &str) -> anyhow::Result> {
+ Ok(None)
+ }
+ async fn list(
+ &self,
+ _c: Option<&MemoryCategory>,
+ _s: Option<&str>,
+ ) -> anyhow::Result> {
+ Ok(vec![])
+ }
+ async fn forget(&self, _k: &str) -> anyhow::Result {
+ Ok(true)
+ }
+ async fn count(&self) -> anyhow::Result {
+ Ok(0)
+ }
+ async fn health_check(&self) -> bool {
+ true
+ }
+ fn name(&self) -> &str {
+ "fact-capture"
+ }
+ }
+
+ let mem = Arc::new(FactCapture {
+ stored: Mutex::new(Vec::new()),
+ });
+ struct FlushThenSummaryProvider {
+ call_count: Mutex,
+ }
+
+ #[async_trait]
+ impl Provider for FlushThenSummaryProvider {
+ async fn chat_with_system(
+ &self,
+ _sp: Option<&str>,
+ _m: &str,
+ _model: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ let mut count = self.call_count.lock().unwrap();
+ *count += 1;
+ if *count == 1 {
+ Ok("[lang] User prefers Rust\n[db] PostgreSQL 16".to_string())
+ } else {
+ Ok("- summarized context".to_string())
+ }
+ }
+ async fn chat(
+ &self,
+ _r: ChatRequest<'_>,
+ _m: &str,
+ _t: f64,
+ ) -> anyhow::Result {
+ Ok(ChatResponse {
+ text: Some("- summarized context".to_string()),
+ tool_calls: vec![],
+ usage: None,
+ reasoning_content: None,
+ quota_metadata: None,
+ stop_reason: None,
+ raw_stop_reason: None,
+ })
+ }
+ }
+
+ // Provider that would return facts if flush_durable_facts were called.
+ let provider = FlushThenSummaryProvider {
+ call_count: Mutex::new(0),
+ };
+ let mut history = (0..25)
+ .map(|i| ChatMessage::user(format!("msg-{i}")))
+ .collect::>();
+
+ // With post_turn_active=true, flush_durable_facts should be skipped.
+ let compacted = auto_compact_history(
+ &mut history,
+ &provider,
+ "test-model",
+ 21,
+ None,
+ Some(mem.as_ref()),
+ None,
+ true, // post_turn_active
+ )
+ .await
+ .expect("compaction should succeed");
+
+ assert!(compacted.0);
+ let stored = mem.stored.lock().unwrap();
+ // No auto-extracted entries should be stored.
+ assert!(
+ stored.iter().all(|(k, _)| !k.starts_with("auto_")),
+ "flush_durable_facts should be skipped when post_turn_active=true"
+ );
+ }
}
diff --git a/src/agent/loop_/parsing.rs b/src/agent/loop_/parsing.rs
index 0ee0629b7..50d2c1e3c 100644
--- a/src/agent/loop_/parsing.rs
+++ b/src/agent/loop_/parsing.rs
@@ -10,6 +10,12 @@ pub(super) struct ParsedToolCall {
pub(super) tool_call_id: Option,
}
+#[derive(Debug, Clone, Default)]
+pub(super) struct StructuredToolCallParseResult {
+ pub(super) calls: Vec,
+ pub(super) invalid_json_arguments: usize,
+}
+
pub(super) fn parse_arguments_value(raw: Option<&serde_json::Value>) -> serde_json::Value {
match raw {
Some(serde_json::Value::String(s)) => serde_json::from_str::(s)
@@ -1676,18 +1682,41 @@ pub(super) fn detect_tool_call_parse_issue(
}
}
-pub(super) fn parse_structured_tool_calls(tool_calls: &[ToolCall]) -> Vec