mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-14 10:09:04 +08:00
Compare commits
1 Commits
codex/test
...
codex/matr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f45bddf13d |
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
@@ -20,8 +20,6 @@ jobs:
|
||||
# Preflight: establish routing truth and job matrices once, then let real
|
||||
# work fan out from a single source of truth.
|
||||
preflight:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
@@ -229,7 +227,7 @@ jobs:
|
||||
check_name: "checks-node-compat-node22",
|
||||
runtime: "node",
|
||||
task: "compat-node22",
|
||||
node_version: "22.18.0",
|
||||
node_version: "22.x",
|
||||
cache_key_suffix: "node22",
|
||||
},
|
||||
]
|
||||
@@ -300,8 +298,6 @@ jobs:
|
||||
# Run the fast security/SCM checks in parallel with scope detection so the
|
||||
# main Node jobs do not have to wait for Python/pre-commit setup.
|
||||
security-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 20
|
||||
@@ -400,8 +396,6 @@ jobs:
|
||||
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
||||
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
||||
build-artifacts:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
@@ -455,8 +449,6 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
checks-fast-core:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
@@ -501,8 +493,6 @@ jobs:
|
||||
esac
|
||||
|
||||
checks-node-extensions-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
@@ -530,8 +520,6 @@ jobs:
|
||||
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
|
||||
|
||||
checks-node-extensions:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-node-extensions
|
||||
needs: [preflight, checks-node-extensions-shard]
|
||||
if: always() && needs.preflight.outputs.run_checks_fast == 'true'
|
||||
@@ -548,8 +536,6 @@ jobs:
|
||||
fi
|
||||
|
||||
checks:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
|
||||
@@ -636,8 +622,6 @@ jobs:
|
||||
esac
|
||||
|
||||
checks-node-core-test-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success'
|
||||
@@ -727,8 +711,6 @@ jobs:
|
||||
EOF
|
||||
|
||||
checks-node-core-test:
|
||||
permissions:
|
||||
contents: read
|
||||
name: checks-node-core
|
||||
needs: [preflight, checks-node-core-test-shard]
|
||||
if: always() && needs.preflight.outputs.run_checks == 'true'
|
||||
@@ -745,8 +727,6 @@ jobs:
|
||||
fi
|
||||
|
||||
extension-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "extension-fast"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_extension_fast == 'true'
|
||||
@@ -775,8 +755,6 @@ jobs:
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check"
|
||||
needs: [preflight]
|
||||
if: always() && needs.preflight.outputs.run_check == 'true'
|
||||
@@ -804,8 +782,6 @@ jobs:
|
||||
run: pnpm build:strict-smoke
|
||||
|
||||
check-additional:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-additional"
|
||||
needs: [preflight]
|
||||
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
||||
@@ -1013,8 +989,6 @@ jobs:
|
||||
exit "$failures"
|
||||
|
||||
build-smoke:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "build-smoke"
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
@@ -1069,8 +1043,6 @@ jobs:
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
@@ -1092,8 +1064,6 @@ jobs:
|
||||
run: pnpm check:docs
|
||||
|
||||
skills-python:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_skills_python_job == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
@@ -1122,8 +1092,6 @@ jobs:
|
||||
run: python -m pytest -q skills
|
||||
|
||||
checks-windows:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.run_checks_windows == 'true' && needs.build-artifacts.result == 'success'
|
||||
@@ -1239,8 +1207,6 @@ jobs:
|
||||
esac
|
||||
|
||||
macos-node:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, build-artifacts]
|
||||
if: always() && needs.preflight.outputs.run_macos_node == 'true' && needs.build-artifacts.result == 'success'
|
||||
@@ -1294,8 +1260,6 @@ jobs:
|
||||
esac
|
||||
|
||||
macos-swift:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "macos-swift"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||||
@@ -1360,8 +1324,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
android:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_android_job == 'true'
|
||||
|
||||
14
.github/workflows/codeql.yml
vendored
14
.github/workflows/codeql.yml
vendored
@@ -2,8 +2,6 @@ name: CodeQL
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
@@ -72,7 +70,7 @@ jobs:
|
||||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -85,13 +83,13 @@ jobs:
|
||||
|
||||
- name: Setup Python
|
||||
if: matrix.needs_python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Java
|
||||
if: matrix.needs_java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
@@ -105,7 +103,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -113,7 +111,7 @@ jobs:
|
||||
|
||||
- name: Autobuild
|
||||
if: matrix.needs_autobuild
|
||||
uses: github/codeql-action/autobuild@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
|
||||
- name: Build Android for CodeQL
|
||||
if: matrix.language == 'java-kotlin'
|
||||
@@ -134,6 +132,6 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
18
.github/workflows/docker-release.yml
vendored
18
.github/workflows/docker-release.yml
vendored
@@ -83,10 +83,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -200,10 +200,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
2
.github/workflows/docs-sync-publish.yml
vendored
2
.github/workflows/docs-sync-publish.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
node-version: 22
|
||||
|
||||
- name: Clone publish repo
|
||||
env:
|
||||
|
||||
13
.github/workflows/install-smoke.yml
vendored
13
.github/workflows/install-smoke.yml
vendored
@@ -7,9 +7,6 @@ on:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -95,12 +92,12 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# Blacksmith can fall back to the local docker driver, which rejects gha
|
||||
# cache export/import. Keep smoke builds driver-agnostic.
|
||||
- name: Build root Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -119,7 +116,7 @@ jobs:
|
||||
# runtime deps declared by the plugin and that matrix discovery stays
|
||||
# healthy in the final runtime image.
|
||||
- name: Build extension Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -177,7 +174,7 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Build installer smoke image
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
file: ./scripts/docker/install-sh-smoke/Dockerfile
|
||||
@@ -188,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Build installer non-root image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
file: ./scripts/docker/install-sh-nonroot/Dockerfile
|
||||
|
||||
11
.github/workflows/parity-gate.yml
vendored
11
.github/workflows/parity-gate.yml
vendored
@@ -33,13 +33,6 @@ jobs:
|
||||
# meaningful verdict without touching a real API. If any of these
|
||||
# leak into the job env, fail hard instead of silently running
|
||||
# against a live provider and burning real budget.
|
||||
#
|
||||
# The parity pack has 11 isolated scenario workers. Letting qa suite
|
||||
# fan out to its default "all scenarios at once" mode on smaller CI
|
||||
# VMs makes the short strict-agentic scenarios flaky, especially the
|
||||
# approval-turn followthrough gate that expects a fast post-approval
|
||||
# read within a 30s agent.wait timeout.
|
||||
QA_PARITY_CONCURRENCY: "2"
|
||||
OPENAI_API_KEY: ""
|
||||
ANTHROPIC_API_KEY: ""
|
||||
OPENCLAW_LIVE_OPENAI_KEY: ""
|
||||
@@ -56,7 +49,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22.18.0"
|
||||
node-version: "22.14.0"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -67,7 +60,6 @@ jobs:
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4-alt \
|
||||
--output-dir .artifacts/qa-e2e/gpt54
|
||||
@@ -77,7 +69,6 @@ jobs:
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model anthropic/claude-opus-4-6 \
|
||||
--alt-model anthropic/claude-sonnet-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/opus46
|
||||
|
||||
5
.github/workflows/sandbox-common-smoke.yml
vendored
5
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -14,9 +14,6 @@ on:
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -35,7 +32,7 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
|
||||
3
.github/workflows/workflow-sanity.yml
vendored
3
.github/workflows/workflow-sanity.yml
vendored
@@ -6,9 +6,6 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -6,43 +6,13 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Google/TTS: add Gemini text-to-speech support to the bundled `google` plugin, including provider registration, voice selection, WAV reply output, PCM telephony output, and setup/docs guidance. (#67515) Thanks @barronlroth.
|
||||
- Anthropic/models: default Anthropic selections, `opus` aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/tools: anchor trusted local `MEDIA:` tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (`400 invalid_request_error` on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303)
|
||||
- Agents/replay recovery: classify the provider wording `401 input item ID does not belong to this connection` as replay-invalid, so users get the existing `/new` session reset guidance instead of a raw 401-style failure. (#66475) Thanks @dallylee.
|
||||
- Gateway/webchat: enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.
|
||||
- Matrix/pairing: block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.
|
||||
- Matrix/commands: keep DM pairing-store approvals out of room control-command authorization so DM-paired-only senders can no longer run owner-style commands in Matrix rooms without explicit room sender authorization.
|
||||
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.
|
||||
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
|
||||
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.
|
||||
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
|
||||
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras.
|
||||
- Agents/context + Memory: trim default startup/skills prompt budgets, cap `memory_get` excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.
|
||||
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
|
||||
- Memory-core/dreaming: skip dreaming narrative transcripts from session-store metadata before bootstrap records land so dream diary prompt/prose lines do not pollute session ingestion. (#67315) thanks @jalehman.
|
||||
- Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF.
|
||||
- Dreaming/memory-core: change the default `dreaming.storage.mode` from `inline` to `separate` so Dreaming phase blocks (`## Light Sleep`, `## REM Sleep`) land in `memory/dreaming/{phase}/YYYY-MM-DD.md` instead of being injected into `memory/YYYY-MM-DD.md`. Daily memory files no longer get dominated by structured candidate output, and the daily-ingestion scanner that already strips dream marker blocks no longer has to compete with hundreds of phase-block lines on every run. Operators who want the previous behavior can opt in by setting `plugins.entries.memory-core.config.dreaming.storage.mode: "inline"`. (#66412) Thanks @mjamiv.
|
||||
- Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine.
|
||||
- Dashboard: constrain exec approval modal overflow on desktop so long command content no longer pushes action buttons out of view. (#67082) Thanks @Ziy1-Tan.
|
||||
- Agents/CLI transcripts: persist successful CLI-backed turns into the OpenClaw session transcript so google-gemini-cli replies appear in session history and the Control UI again. (#67490) Thanks @obviyus.
|
||||
- Discord/tool-call text: strip standalone Gemma-style `<function>...</function>` tool-call payloads from visible assistant text without truncating prose examples or trailing replies. (#67318) Thanks @joelnishanth.
|
||||
- WhatsApp/web-session: drain the pending per-auth creds save queue before reopening sockets so reconnect-time auth bootstrap no longer races in-flight `creds.json` writes and falsely restores from backup. (#67464) Thanks @neeravmakwana.
|
||||
- BlueBubbles/catchup: add a per-message retry ceiling (`catchup.maxFailureRetries`, default 10) so a persistently-failing message with a malformed payload no longer wedges the catchup cursor forever. After N consecutive `processMessage` failures against the same GUID, catchup logs a WARN, skips that message on subsequent sweeps, and lets the cursor advance past it. Transient failures still retry from the same point as before. Also fixes a lost-update race in the persistent dedupe file lock that silently dropped inbound GUIDs on concurrent writes, a dedupe file naming migration gap on version upgrade, and a balloon-event bypass that let catchup replay debouncer-coalesced events as standalone messages. (#67426, #66870) Thanks @omarshahine.
|
||||
- Ollama/chat: strip the `ollama/` provider prefix from Ollama chat request model ids so configured refs like `ollama/qwen3:14b-q8_0` stop 404ing against the Ollama API. (#67457) Thanks @suboss87.
|
||||
- QA/Matrix: split the private QA lab runtime into smaller tested modules, add Matrix media contract coverage for image understanding and generated-image delivery, and update the memory-dreaming QA sweep to assert the separate phase-report layout. (#67430) Thanks @gumadeiras.
|
||||
- Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so `~/...` host edit/write operations stop failing or reading back the wrong file when `OPENCLAW_HOME` differs. (#62804) Thanks @stainlu.
|
||||
- Speech/TTS: auto-enable the bundled Microsoft and ElevenLabs speech providers, and route generic TTS directive tokens through the explicit or active provider first so overrides like `[[tts:speed=1.2]]` stop silently landing on the wrong provider. (#62846) Thanks @stainlu.
|
||||
- OpenAI Codex/models: normalize stale native transport metadata in both runtime resolution and discovery/listing so legacy `openai-codex` rows with missing `api` or `https://chatgpt.com/backend-api/v1` self-heal to the canonical Codex transport instead of routing requests through broken HTML/Cloudflare paths, combining the original fixes proposed in #66969 (saamuelng601-pixel) and #67159 (hclsys). (#67635)
|
||||
- Agents/failover: treat HTML provider error pages as upstream transport failures for CDN-style 5xx responses without misclassifying embedded body text as API rate limits, while still preserving auth remediation for HTML 401/403 pages and proxy remediation for HTML 407 pages. (#67642) Thanks @stainlu.
|
||||
- Gateway/skills: bump the cached skills-snapshot version whenever a config write touches `skills.*` (for example `skills.allowBundled`, `skills.entries.<id>.enabled`, or `skills.profile`). Existing agent sessions persist a `skillsSnapshot` in `sessions.json` that reuses the skill list frozen at session creation; without this invalidation, removing a bundled skill from the allowlist left the old snapshot live and the model kept calling the disabled tool, producing `Tool <name> not found` loops that ran until the embedded-run timeout. (#67401) Thanks @xantorres.
|
||||
- Agents/tool-loop: enable the unknown-tool stream guard by default. Previously `resolveUnknownToolGuardThreshold` returned `undefined` unless `tools.loopDetection.enabled` was explicitly set to `true`, which left the protection off in the default configuration. A hallucinated or removed tool (for example `himalaya` after it was dropped from `skills.allowBundled`) would then loop "Tool X not found" attempts until the full embedded-run timeout. The guard has no false-positive surface because it only triggers on tools that are objectively not registered in the run, so it now stays on regardless of `tools.loopDetection.enabled` and still accepts `tools.loopDetection.unknownToolThreshold` as a per-run override (default 10). (#67401) Thanks @xantorres.
|
||||
- TUI/streaming: add a client-side streaming watchdog to `tui-event-handlers` so the `streaming · Xm Ys` activity indicator resets to `idle` after 30s of delta silence on the active run. Guards against lost or late `state: "final"` chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on `streaming` indefinitely; a new system log line surfaces the reset so users know to send a new message to resync. The window is configurable via the new `streamingWatchdogMs` context option (set to `0` to disable), and the handler now exposes a `dispose()` that clears the pending timer on shutdown. (#67401) Thanks @xantorres.
|
||||
- Extensions/lmstudio: add exponential backoff to the inference-preload wrapper so an LM Studio model-load failure (for example the built-in memory guardrail rejecting a load because the swap is saturated) no longer produces a WARN line every ~2s for every chat request. The wrapper now records consecutive preload failures per `(baseUrl, modelKey, contextLength)` tuple with a 5s → 10s → 20s → … → 5min cooldown and skips the preload step entirely while a cooldown is active, letting chat requests proceed directly to the stream (the model is often already loaded via the LM Studio UI). The combined `preload failed` log line now reports consecutive-failure count and remaining cooldown so operators can act on the real issue instead of drowning in repeated warnings. (#67401) Thanks @xantorres.
|
||||
- Agents/replay: re-run tool/result pairing after strict replay tool-call ID sanitization on outbound requests so Anthropic-compatible providers like MiniMax no longer receive malformed orphan tool-result IDs such as `...toolresult1` during compaction and retry flows. (#67620) Thanks @stainlu.
|
||||
- Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf
|
||||
|
||||
## 2026.4.15-beta.1
|
||||
|
||||
@@ -53,6 +23,8 @@ Docs: https://docs.openclaw.ai
|
||||
- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.
|
||||
- Agents/local models: add experimental `agents.defaults.experimental.localModelLean: true` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.
|
||||
- Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.
|
||||
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
|
||||
- Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -99,18 +71,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.
|
||||
- Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to `.csv` or `.md` slip past the host-read guard. (#67047) Thanks @Unayung.
|
||||
- Ollama/onboarding: split setup into `Cloud + Local`, `Cloud only`, and `Local only`, support direct `OLLAMA_API_KEY` cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.
|
||||
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) Thanks @ly85206559.
|
||||
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
|
||||
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) Thanks @neo1027144-creator.
|
||||
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
|
||||
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) Thanks @gumadeiras.
|
||||
- Agents/context + Memory: trim default startup/skills prompt budgets, cap `memory_get` excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.
|
||||
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
|
||||
- Matrix/security: block DM pairing-store entries from authorizing room control commands. (#67294) Thanks @pgondhi987.
|
||||
- Gateway/security: enforce `localRoots` containment on the webchat audio embedding path. (#67298) Thanks @pgondhi987.
|
||||
- Webchat/security: reject remote-host `file://` URLs in the media embedding path. (#67293) Thanks @pgondhi987.
|
||||
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
|
||||
- Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like `/usr/bin/whoami` no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.
|
||||
|
||||
## 2026.4.14
|
||||
|
||||
@@ -190,7 +150,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.
|
||||
- Plugins/setup-entry: preserve separate setup-entry secrets exports when loading bundled setup-runtime channels, so setup-mode flows keep the channel secret contract for split plugin + secrets entrypoints. (#66261) Thanks @hxy91819.
|
||||
- CLI/update: prune stale packaged `dist` chunks after npm upgrades, verify installed package inventory, and keep downgrade/update verification working across older releases. (#66959) Thanks @obviyus.
|
||||
- Gateway/exec events: dedupe replayed `exec.finished` node events by canonical session key plus `runId` so duplicate async completion replays no longer inject duplicate completion turns into the parent session transcript. (#67281) thanks @jalehman.
|
||||
|
||||
## 2026.4.12
|
||||
|
||||
@@ -456,7 +415,6 @@ Docs: https://docs.openclaw.ai
|
||||
- iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, so brief local transport stalls do not immediately bounce the channel. (#65393) Thanks @vincentkoc.
|
||||
- Status/session_status: move shared session status text into a neutral internal status module and keep the tool importing a local runtime shim, so built `session_status` no longer depends on reply command internals or a bundler-opaque runtime import. (#65807) Thanks @dutifulbob.
|
||||
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
|
||||
- WhatsApp/web: rewrite queued `creds.json` updates atomically so interrupted saves do not leave truncated login state behind. (#63577) thanks @OwenYWT
|
||||
|
||||
## 2026.4.9
|
||||
|
||||
|
||||
712
README.md
712
README.md
@@ -19,19 +19,16 @@
|
||||
</p>
|
||||
|
||||
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
|
||||
It answers you on the channels you already use. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, QQ, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||
|
||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||
|
||||
Supported channels include: WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WeChat, QQ, WebChat.
|
||||
|
||||
[Website](https://openclaw.ai) · [Docs](https://docs.openclaw.ai) · [Vision](VISION.md) · [DeepWiki](https://deepwiki.com/openclaw/openclaw) · [Getting Started](https://docs.openclaw.ai/start/getting-started) · [Updating](https://docs.openclaw.ai/install/updating) · [Showcase](https://docs.openclaw.ai/start/showcase) · [FAQ](https://docs.openclaw.ai/help/faq) · [Onboarding](https://docs.openclaw.ai/start/wizard) · [Nix](https://github.com/openclaw/nix-openclaw) · [Docker](https://docs.openclaw.ai/install/docker) · [Discord](https://discord.gg/clawd)
|
||||
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
Preferred setup: run `openclaw onboard` in your terminal.
|
||||
OpenClaw Onboard guides you step by step through setting up the gateway, workspace, channels, and skills. It is the recommended CLI setup path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
|
||||
Works with npm, pnpm, or bun.
|
||||
New install? Start here: [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -94,6 +91,11 @@ Works with npm, pnpm, or bun.
|
||||
|
||||
Model note: while many providers and models are supported, prefer a current flagship model from the provider you trust and already use. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
- Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models)
|
||||
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover)
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
@@ -127,7 +129,34 @@ openclaw agent --message "Ship checklist" --thinking high
|
||||
|
||||
Upgrading? [Updating guide](https://docs.openclaw.ai/install/updating) (and run `openclaw doctor`).
|
||||
|
||||
Models config + CLI: [Models](https://docs.openclaw.ai/concepts/models). Auth profile rotation + fallbacks: [Model failover](https://docs.openclaw.ai/concepts/model-failover).
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
|
||||
## Security defaults (DM access)
|
||||
|
||||
@@ -154,30 +183,152 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
|
||||
- **[Onboarding](https://docs.openclaw.ai/start/wizard) + [skills](https://docs.openclaw.ai/tools/skills)** — onboarding-driven setup with bundled/managed/workspace skills.
|
||||
|
||||
## Security model (important)
|
||||
## Star History
|
||||
|
||||
- Default: tools run on the host for the `main` session, so the agent has full access when it is just you.
|
||||
- Group/channel safety: set `agents.defaults.sandbox.mode: "non-main"` to run non-`main` sessions inside per-session Docker sandboxes.
|
||||
- Typical sandbox default: allow `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; deny `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
- Before exposing anything remotely, read [Security](https://docs.openclaw.ai/gateway/security), [Docker sandboxing](https://docs.openclaw.ai/install/docker), and [Configuration](https://docs.openclaw.ai/gateway/configuration).
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
|
||||
## Operator quick refs
|
||||
## Everything we built so far
|
||||
|
||||
- Chat commands: `/status`, `/new`, `/reset`, `/compact`, `/think <level>`, `/verbose on|off`, `/trace on|off`, `/usage off|tokens|full`, `/restart`, `/activation mention|always`
|
||||
- Session tools: `sessions_list`, `sessions_history`, `sessions_send`
|
||||
- Skills registry: [ClawHub](https://clawhub.com)
|
||||
- Architecture overview: [Architecture](https://docs.openclaw.ai/concepts/architecture)
|
||||
### Core platform
|
||||
|
||||
## Docs by goal
|
||||
- [Gateway WS control plane](https://docs.openclaw.ai/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.openclaw.ai/web), and [Canvas host](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
|
||||
- [CLI surface](https://docs.openclaw.ai/tools/agent-send): gateway, agent, send, [onboarding](https://docs.openclaw.ai/start/wizard), and [doctor](https://docs.openclaw.ai/gateway/doctor).
|
||||
- [Pi agent runtime](https://docs.openclaw.ai/concepts/agent) in RPC mode with tool streaming and block streaming.
|
||||
- [Session model](https://docs.openclaw.ai/concepts/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.openclaw.ai/channels/groups).
|
||||
- [Media pipeline](https://docs.openclaw.ai/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.openclaw.ai/nodes/audio).
|
||||
|
||||
- New here: [Getting started](https://docs.openclaw.ai/start/getting-started), [Onboarding](https://docs.openclaw.ai/start/wizard), [Updating](https://docs.openclaw.ai/install/updating)
|
||||
- Channel setup: [Channels index](https://docs.openclaw.ai/channels), [WhatsApp](https://docs.openclaw.ai/channels/whatsapp), [Telegram](https://docs.openclaw.ai/channels/telegram), [Discord](https://docs.openclaw.ai/channels/discord), [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
- Apps + nodes: [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android), [Nodes](https://docs.openclaw.ai/nodes)
|
||||
- Config + security: [Configuration](https://docs.openclaw.ai/gateway/configuration), [Security](https://docs.openclaw.ai/gateway/security), [Docker sandboxing](https://docs.openclaw.ai/install/docker)
|
||||
- Remote + web: [Gateway](https://docs.openclaw.ai/gateway), [Remote access](https://docs.openclaw.ai/gateway/remote), [Tailscale](https://docs.openclaw.ai/gateway/tailscale), [Web surfaces](https://docs.openclaw.ai/web)
|
||||
- Tools + automation: [Tools](https://docs.openclaw.ai/tools), [Skills](https://docs.openclaw.ai/tools/skills), [Cron jobs](https://docs.openclaw.ai/automation/cron-jobs), [Webhooks](https://docs.openclaw.ai/automation/webhook), [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- Internals: [Architecture](https://docs.openclaw.ai/concepts/architecture), [Agent](https://docs.openclaw.ai/concepts/agent), [Session model](https://docs.openclaw.ai/concepts/session), [Gateway protocol](https://docs.openclaw.ai/reference/rpc)
|
||||
- Troubleshooting: [Channel troubleshooting](https://docs.openclaw.ai/channels/troubleshooting), [Logging](https://docs.openclaw.ai/logging), [Docs home](https://docs.openclaw.ai)
|
||||
### Channels
|
||||
|
||||
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), WeChat (`@tencent-weixin/openclaw-weixin`), [QQ](https://docs.openclaw.ai/channels/qqbot), [WebChat](https://docs.openclaw.ai/web/webchat).
|
||||
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
|
||||
|
||||
### Apps + nodes
|
||||
|
||||
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour + device pairing.
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android): Connect tab (setup code/manual), chat sessions, voice tab, [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), camera/screen recording, and Android device commands (notifications/location/SMS/photos/contacts/calendar/motion/app update).
|
||||
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
|
||||
|
||||
### Tools + automation
|
||||
|
||||
- [Browser control](https://docs.openclaw.ai/tools/browser): dedicated openclaw Chrome/Chromium, snapshots, actions, uploads, profiles.
|
||||
- [Canvas](https://docs.openclaw.ai/platforms/mac/canvas): [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
|
||||
- [Nodes](https://docs.openclaw.ai/nodes): camera snap/clip, screen record, [location.get](https://docs.openclaw.ai/nodes/location-command), notifications.
|
||||
- [Cron + wakeups](https://docs.openclaw.ai/automation/cron-jobs); [webhooks](https://docs.openclaw.ai/automation/webhook); [Gmail Pub/Sub](https://docs.openclaw.ai/automation/gmail-pubsub).
|
||||
- [Skills platform](https://docs.openclaw.ai/tools/skills): bundled, managed, and workspace skills with install gating + UI.
|
||||
|
||||
### Runtime + safety
|
||||
|
||||
- [Channel routing](https://docs.openclaw.ai/channels/channel-routing), [retry policy](https://docs.openclaw.ai/concepts/retry), and [streaming/chunking](https://docs.openclaw.ai/concepts/streaming).
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence), [typing indicators](https://docs.openclaw.ai/concepts/typing-indicators), and [usage tracking](https://docs.openclaw.ai/concepts/usage-tracking).
|
||||
- [Models](https://docs.openclaw.ai/concepts/models), [model failover](https://docs.openclaw.ai/concepts/model-failover), and [session pruning](https://docs.openclaw.ai/concepts/session-pruning).
|
||||
- [Security](https://docs.openclaw.ai/gateway/security) and [troubleshooting](https://docs.openclaw.ai/channels/troubleshooting).
|
||||
|
||||
### Ops + packaging
|
||||
|
||||
- [Control UI](https://docs.openclaw.ai/web) + [WebChat](https://docs.openclaw.ai/web/webchat) served directly from the Gateway.
|
||||
- [Tailscale Serve/Funnel](https://docs.openclaw.ai/gateway/tailscale) or [SSH tunnels](https://docs.openclaw.ai/gateway/remote) with token/password auth.
|
||||
- [Nix mode](https://docs.openclaw.ai/install/nix) for declarative config; [Docker](https://docs.openclaw.ai/install/docker)-based installs.
|
||||
- [Doctor](https://docs.openclaw.ai/gateway/doctor) migrations, [logging](https://docs.openclaw.ai/logging).
|
||||
|
||||
## How it works (short)
|
||||
|
||||
```
|
||||
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WeChat / QQ / WebChat
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────┐
|
||||
│ Gateway │
|
||||
│ (control plane) │
|
||||
│ ws://127.0.0.1:18789 │
|
||||
└──────────────┬────────────────┘
|
||||
│
|
||||
├─ Pi agent (RPC)
|
||||
├─ CLI (openclaw …)
|
||||
├─ WebChat UI
|
||||
├─ macOS app
|
||||
└─ iOS / Android nodes
|
||||
```
|
||||
|
||||
## Key subsystems
|
||||
|
||||
- **[Gateway WebSocket network](https://docs.openclaw.ai/concepts/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.openclaw.ai/gateway)).
|
||||
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
|
||||
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
|
||||
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
|
||||
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS plus continuous voice on Android.
|
||||
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
|
||||
|
||||
## Tailscale access (Gateway dashboard)
|
||||
|
||||
OpenClaw can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:
|
||||
|
||||
- `off`: no Tailscale automation (default).
|
||||
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
|
||||
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).
|
||||
|
||||
Notes:
|
||||
|
||||
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (OpenClaw enforces this).
|
||||
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
|
||||
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
|
||||
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.
|
||||
|
||||
Details: [Tailscale guide](https://docs.openclaw.ai/gateway/tailscale) · [Web surfaces](https://docs.openclaw.ai/web)
|
||||
|
||||
## Remote Gateway (Linux is great)
|
||||
|
||||
It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.
|
||||
|
||||
- **Gateway host** runs the exec tool and channel connections by default.
|
||||
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
|
||||
In short: exec runs where the Gateway lives; device actions run where the device lives.
|
||||
|
||||
Details: [Remote access](https://docs.openclaw.ai/gateway/remote) · [Nodes](https://docs.openclaw.ai/nodes) · [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## macOS permissions via the Gateway protocol
|
||||
|
||||
The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:
|
||||
|
||||
- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
|
||||
- `system.notify` posts a user notification and fails if notifications are denied.
|
||||
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.
|
||||
|
||||
Elevated bash (host permissions) is separate from macOS TCC:
|
||||
|
||||
- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
|
||||
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.
|
||||
|
||||
Details: [Nodes](https://docs.openclaw.ai/nodes) · [macOS app](https://docs.openclaw.ai/platforms/macos) · [Gateway protocol](https://docs.openclaw.ai/concepts/architecture)
|
||||
|
||||
## Agent to Agent (sessions\_\* tools)
|
||||
|
||||
- Use these to coordinate work across sessions without jumping between chat surfaces.
|
||||
- `sessions_list` — discover active sessions (agents) and their metadata.
|
||||
- `sessions_history` — fetch transcript logs for a session.
|
||||
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).
|
||||
|
||||
Details: [Session tools](https://docs.openclaw.ai/concepts/session-tool)
|
||||
|
||||
## Skills registry (ClawHub)
|
||||
|
||||
ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search for skills automatically and pull in new ones as needed.
|
||||
|
||||
[ClawHub](https://clawhub.com)
|
||||
|
||||
## Chat commands
|
||||
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/trace on|off` — plugin trace/debug lines only
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
## Apps (optional)
|
||||
|
||||
@@ -208,35 +359,6 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
- Exposes Connect/Chat/Voice tabs plus Canvas, Camera, Screen capture, and Android device command families.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
|
||||
## From source (development)
|
||||
|
||||
Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/openclaw.git
|
||||
cd openclaw
|
||||
|
||||
pnpm install
|
||||
pnpm ui:build # auto-installs UI deps on first run
|
||||
pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
Note: `pnpm openclaw ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `openclaw` binary.
|
||||
|
||||
## Development channels
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`), npm dist-tag `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`), npm dist-tag `beta` (macOS app may be missing).
|
||||
- **dev**: moving head of `main`, npm dist-tag `dev` (when published).
|
||||
|
||||
Switch channels (git + npm): `openclaw update --channel stable|beta|dev`.
|
||||
Details: [Development channels](https://docs.openclaw.ai/install/development-channels).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
- Workspace root: `~/.openclaw/workspace` (configurable via `agents.defaults.workspace`).
|
||||
@@ -257,9 +379,179 @@ Minimal `~/.openclaw/openclaw.json` (model + defaults):
|
||||
|
||||
[Full configuration reference (all keys + examples).](https://docs.openclaw.ai/gateway/configuration)
|
||||
|
||||
## Star History
|
||||
## Security model (important)
|
||||
|
||||
[](https://www.star-history.com/#openclaw/openclaw&type=date&legend=top-left)
|
||||
- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
|
||||
- **Group/channel safety:** set `agents.defaults.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
|
||||
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.
|
||||
|
||||
Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker + sandboxing](https://docs.openclaw.ai/install/docker) · [Sandbox config](https://docs.openclaw.ai/gateway/configuration)
|
||||
|
||||
### [WhatsApp](https://docs.openclaw.ai/channels/whatsapp)
|
||||
|
||||
- Link the device: `pnpm openclaw channels login` (stores creds in `~/.openclaw/credentials`).
|
||||
- Allowlist who can talk to the assistant via `channels.whatsapp.allowFrom`.
|
||||
- If `channels.whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Telegram](https://docs.openclaw.ai/channels/telegram)
|
||||
|
||||
- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` (env wins).
|
||||
- Optional: set `channels.telegram.groups` (with `channels.telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `channels.telegram.allowFrom` or `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: "123456:ABCDEF",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### [Slack](https://docs.openclaw.ai/channels/slack)
|
||||
|
||||
- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `channels.slack.botToken` + `channels.slack.appToken`).
|
||||
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
token: "1234abcd",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### [Signal](https://docs.openclaw.ai/channels/signal)
|
||||
|
||||
- Requires `signal-cli` and a `channels.signal` config section.
|
||||
|
||||
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
|
||||
|
||||
- **Recommended** iMessage integration.
|
||||
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
|
||||
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
|
||||
|
||||
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
|
||||
|
||||
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
|
||||
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
|
||||
|
||||
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)
|
||||
|
||||
- Configure a Teams app + Bot Framework, then add a `msteams` config section.
|
||||
- Allowlist who can talk via `msteams.allowFrom`; group access via `msteams.groupAllowFrom` or `msteams.groupPolicy: "open"`.
|
||||
|
||||
### [QQ](https://docs.openclaw.ai/channels/qqbot)
|
||||
|
||||
- Go to the QQ Open Platform and scan the QR code with your phone QQ to register / log in.
|
||||
- Click **Create Bot** to create a new QQ bot. Find **AppID** and **AppSecret** on the bot's settings page and copy them.
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qqbot": {
|
||||
"enabled": true,
|
||||
"appId": "YOUR_APP_ID",
|
||||
"clientSecret": "YOUR_APP_SECRET"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### WeChat
|
||||
|
||||
- Official Tencent plugin via [`@tencent-weixin/openclaw-weixin`](https://www.npmjs.com/package/@tencent-weixin/openclaw-weixin) (iLink Bot API). Private chats only; v2.x requires OpenClaw `>=2026.3.22`.
|
||||
- Install: `openclaw plugins install "@tencent-weixin/openclaw-weixin"`, then `openclaw channels login --channel openclaw-weixin` to scan the QR code.
|
||||
- Requires the WeChat ClawBot plugin (WeChat > Me > Settings > Plugins); gradual rollout by Tencent.
|
||||
|
||||
### [WebChat](https://docs.openclaw.ai/web/webchat)
|
||||
|
||||
- Uses the Gateway WebSocket; no separate WebChat port/config.
|
||||
|
||||
Browser control (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
color: "#FF4500",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
Use these when you’re past the onboarding flow and want the deeper reference.
|
||||
|
||||
- [Start with the docs index for navigation and “what’s where.”](https://docs.openclaw.ai)
|
||||
- [Read the architecture overview for the gateway + protocol model.](https://docs.openclaw.ai/concepts/architecture)
|
||||
- [Use the full configuration reference when you need every key and example.](https://docs.openclaw.ai/gateway/configuration)
|
||||
- [Run the Gateway by the book with the operational runbook.](https://docs.openclaw.ai/gateway)
|
||||
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.openclaw.ai/web)
|
||||
- [Understand remote access over SSH tunnels or tailnets.](https://docs.openclaw.ai/gateway/remote)
|
||||
- [Follow OpenClaw Onboard for a guided setup.](https://docs.openclaw.ai/start/wizard)
|
||||
- [Wire external triggers via the webhook surface.](https://docs.openclaw.ai/automation/webhook)
|
||||
- [Set up Gmail Pub/Sub triggers.](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
- [Learn the macOS menu bar companion details.](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [Platform guides: Windows (WSL2)](https://docs.openclaw.ai/platforms/windows), [Linux](https://docs.openclaw.ai/platforms/linux), [macOS](https://docs.openclaw.ai/platforms/macos), [iOS](https://docs.openclaw.ai/platforms/ios), [Android](https://docs.openclaw.ai/platforms/android)
|
||||
- [Debug common failures with the troubleshooting guide.](https://docs.openclaw.ai/channels/troubleshooting)
|
||||
- [Review security guidance before exposing anything.](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
## Advanced docs (discovery + control)
|
||||
|
||||
- [Discovery + transports](https://docs.openclaw.ai/gateway/discovery)
|
||||
- [Bonjour/mDNS](https://docs.openclaw.ai/gateway/bonjour)
|
||||
- [Gateway pairing](https://docs.openclaw.ai/gateway/pairing)
|
||||
- [Remote gateway README](https://docs.openclaw.ai/gateway/remote-gateway-readme)
|
||||
- [Control UI](https://docs.openclaw.ai/web/control-ui)
|
||||
- [Dashboard](https://docs.openclaw.ai/web/dashboard)
|
||||
|
||||
## Operations & troubleshooting
|
||||
|
||||
- [Health checks](https://docs.openclaw.ai/gateway/health)
|
||||
- [Gateway lock](https://docs.openclaw.ai/gateway/gateway-lock)
|
||||
- [Background process](https://docs.openclaw.ai/gateway/background-process)
|
||||
- [Browser troubleshooting (Linux)](https://docs.openclaw.ai/tools/browser-linux-troubleshooting)
|
||||
- [Logging](https://docs.openclaw.ai/logging)
|
||||
|
||||
## Deep dives
|
||||
|
||||
- [Agent loop](https://docs.openclaw.ai/concepts/agent-loop)
|
||||
- [Presence](https://docs.openclaw.ai/concepts/presence)
|
||||
- [TypeBox schemas](https://docs.openclaw.ai/concepts/typebox)
|
||||
- [RPC adapters](https://docs.openclaw.ai/reference/rpc)
|
||||
- [Queue](https://docs.openclaw.ai/concepts/queue)
|
||||
|
||||
## Workspace & skills
|
||||
|
||||
- [Skills config](https://docs.openclaw.ai/tools/skills-config)
|
||||
- [Default AGENTS](https://docs.openclaw.ai/reference/AGENTS.default)
|
||||
- [Templates: AGENTS](https://docs.openclaw.ai/reference/templates/AGENTS)
|
||||
- [Templates: BOOTSTRAP](https://docs.openclaw.ai/reference/templates/BOOTSTRAP)
|
||||
- [Templates: IDENTITY](https://docs.openclaw.ai/reference/templates/IDENTITY)
|
||||
- [Templates: SOUL](https://docs.openclaw.ai/reference/templates/SOUL)
|
||||
- [Templates: TOOLS](https://docs.openclaw.ai/reference/templates/TOOLS)
|
||||
- [Templates: USER](https://docs.openclaw.ai/reference/templates/USER)
|
||||
|
||||
## Platform internals
|
||||
|
||||
- [macOS dev setup](https://docs.openclaw.ai/platforms/mac/dev-setup)
|
||||
- [macOS menu bar](https://docs.openclaw.ai/platforms/mac/menu-bar)
|
||||
- [macOS voice wake](https://docs.openclaw.ai/platforms/mac/voicewake)
|
||||
- [iOS node](https://docs.openclaw.ai/platforms/ios)
|
||||
- [Android node](https://docs.openclaw.ai/platforms/android)
|
||||
- [Windows (WSL2)](https://docs.openclaw.ai/platforms/windows)
|
||||
- [Linux app](https://docs.openclaw.ai/platforms/linux)
|
||||
|
||||
## Email hooks (Gmail)
|
||||
|
||||
- [docs.openclaw.ai/gmail-pubsub](https://docs.openclaw.ai/automation/gmail-pubsub)
|
||||
|
||||
## Molty
|
||||
|
||||
@@ -278,257 +570,63 @@ AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
||||
Special thanks to Adam Doppelt for the lobster.bot domain.
|
||||
Special thanks to Adam Doppelt for lobster.bot.
|
||||
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<!-- clawtributors:start -->
|
||||
|
||||
[](https://github.com/steipete) [](https://github.com/vincentkoc) [](https://github.com/Takhoffman) [](https://github.com/obviyus) [](https://github.com/gumadeiras) [](https://github.com/mbelinky) [](https://github.com/vignesh07) [](https://github.com/joshavant) [](https://github.com/scoootscooob) [](https://github.com/jacobtomlinson)
|
||||
|
||||
[](https://github.com/shakkernerd) [](https://github.com/sebslight) [](https://github.com/tyler6204) [](https://github.com/ngutman) [](https://github.com/thewilloftheshadow) [](https://github.com/Sid-Qin) [](https://github.com/mcaxtr) [](https://github.com/eleqtrizit) [](https://github.com/BunsDev) [](https://github.com/cpojer)
|
||||
|
||||
[](https://github.com/Glucksberg) [](https://github.com/osolmaz) [](https://github.com/bmendonca3) [](https://github.com/jalehman) [](https://github.com/huntharo) [](https://github.com/neeravmakwana) [](https://github.com/openperf) [](https://github.com/joshp123) [](https://github.com/pgondhi987) [](https://github.com/altaywtf)
|
||||
|
||||
[](https://github.com/quotentiroler) [](https://github.com/liuxiaopai-ai) [](https://github.com/rodrigouroz) [](https://github.com/frankekn) [](https://github.com/drobison00) [](https://github.com/zerone0x) [](https://github.com/onutc) [](https://github.com/ademczuk) [](https://github.com/ImLukeF) [](https://github.com/hydro13)
|
||||
|
||||
[](https://github.com/hxy91819) [](https://github.com/coygeek) [](https://github.com/dutifulbob) [](https://github.com/sliverp) [](https://github.com/0xRaini) [](https://github.com/robbyczgw-cla) [](https://github.com/joelnishanth) [](https://github.com/echoVic) [](https://github.com/sallyom) [](https://github.com/yinghaosang)
|
||||
|
||||
[](https://github.com/BradGroux) [](https://github.com/christianklotz) [](https://github.com/odysseus0) [](https://github.com/hclsys) [](https://github.com/byungsker) [](https://github.com/pashpashpash) [](https://github.com/stakeswky) [![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4&s=48)](https://github.com/apps/github-actions) [](https://github.com/xinhuagu) [](https://github.com/MonkeyLeeT)
|
||||
|
||||
[](https://github.com/100yenadmin) [](https://github.com/mcinteerj) [](https://github.com/samzong) [](https://github.com/chilu18) [](https://github.com/darkamenosa) [](https://github.com/widingmarcus-cyber) [](https://github.com/cgdusek) [](https://github.com/Lukavyi) [](https://github.com/davidrudduck) [](https://github.com/VACInc)
|
||||
|
||||
[](https://github.com/MoerAI) [](https://github.com/velvet-shark) [](https://github.com/HenryLoenwind) [](https://github.com/omarshahine) [](https://github.com/bohdanpodvirnyi) [](https://github.com/VeriteIgiraneza) [](https://github.com/akramcodez) [](https://github.com/Kaneki-x) [](https://github.com/aether-ai-agent) [](https://github.com/joaohlisboa)
|
||||
|
||||
[](https://github.com/MaudeBot) [](https://github.com/davidguttman) [](https://github.com/justinhuangcode) [](https://github.com/lml2468) [](https://github.com/wirjo) [](https://github.com/iHildy) [](https://github.com/mudrii) [](https://github.com/advaitpaliwal) [](https://github.com/czekaj) [](https://github.com/dlauer)
|
||||
|
||||
[](https://github.com/Solvely-Colin) [](https://github.com/feiskyer) [](https://github.com/brandonwise) [](https://github.com/conroywhitney) [](https://github.com/mneves75) [](https://github.com/jaydenfyi) [](https://github.com/davemorin) [](https://github.com/joeykrug) [](https://github.com/kevinWangSheng) [](https://github.com/pejmanjohn)
|
||||
|
||||
[](https://github.com/Lanfei) [](https://github.com/liuy) [](https://github.com/lc0rp) [](https://github.com/teconomix) [](https://github.com/omair445) [](https://github.com/dorukardahan) [](https://github.com/mmaps) [](https://github.com/tobiasbischoff) [](https://github.com/adhitShet) [](https://github.com/pandego)
|
||||
|
||||
[](https://github.com/bradleypriest) [](https://github.com/bjesuiter) [](https://github.com/grp06) [](https://github.com/shadril238) [](https://github.com/kesku) [](https://github.com/YuriNachos) [](https://github.com/vrknetha) [](https://github.com/smartprogrammer93) [](https://github.com/Nachx639) [](https://github.com/jnMetaCode)
|
||||
|
||||
[](https://github.com/Phineas1500) [](https://github.com/dingn42) [](https://github.com/geekhuashan) [](https://github.com/Nanako0129) [](https://github.com/AytuncYildizli) [](https://github.com/BruceMacD) [](https://github.com/jjjojoj) [](https://github.com/mvanhorn) [](https://github.com/bugkill3r) [](https://github.com/rahthakor)
|
||||
|
||||
[](https://github.com/GodsBoy) [](https://github.com/SARAMALI15792) [](https://github.com/radek-paclt) [](https://github.com/Elarwei001) [](https://github.com/ingyukoh) [](https://github.com/SnowSky1) [](https://github.com/lewiswigmore) [](https://github.com/solavrc) [](https://github.com/aldoeliacim) [](https://github.com/jrusz)
|
||||
|
||||
[](https://github.com/tonydehnke) [](https://github.com/roshanasingh4) [](https://github.com/zssggle-rgb) [](https://github.com/adam91holt) [](https://github.com/graysurf) [](https://github.com/xadenryan) [](https://github.com/sfo2001) [](https://github.com/orlyjamie) [](https://github.com/hsrvc) [](https://github.com/tomsun28)
|
||||
|
||||
[](https://github.com/BillChirico) [](https://github.com/carrotRakko) [](https://github.com/ranausmanai) [](https://github.com/arkyu2077) [](https://github.com/hoyyeva) [](https://github.com/luoyanglang) [](https://github.com/sibbl) [](https://github.com/gregmousseau) [](https://github.com/sahilsatralkar) [](https://github.com/akoscz)
|
||||
|
||||
[](https://github.com/rrenamed) [](https://github.com/YuzuruS) [](https://github.com/Marvae) [](https://github.com/mitchmcalister) [](https://github.com/juanpablodlc) [](https://github.com/shtse8) [](https://github.com/thebenignhacker) [](https://github.com/nimbleenigma) [](https://github.com/Linux2010) [](https://github.com/shichangs)
|
||||
|
||||
[](https://github.com/efe-arv) [](https://github.com/hsiaoa) [](https://github.com/nabbilkhan) [](https://github.com/ayanesakura) [](https://github.com/lupuletic) [](https://github.com/polooooo) [](https://github.com/xaeon2026) [](https://github.com/shrey150) [](https://github.com/taw0002) [](https://github.com/dinakars777)
|
||||
|
||||
[](https://github.com/giulio-leone) [](https://github.com/nyanjou) [](https://github.com/meaningfool) [](https://github.com/kunalk16) [](https://github.com/ide-rea) [](https://github.com/JonathanJing) [](https://github.com/yelog) [](https://github.com/markmusson) [](https://github.com/kiranvk-2011) [](https://github.com/Sathvik-Chowdary-Veerapaneni)
|
||||
|
||||
[](https://github.com/rogerdigital) [](https://github.com/artwalker) [](https://github.com/azade-c) [](https://github.com/chinar-amrutkar) [](https://github.com/maxsumrall) [](https://github.com/Minidoracat) [](https://github.com/unisone) [](https://github.com/ly85206559) [](https://github.com/theSamPadilla) [](https://github.com/AnonO6)
|
||||
|
||||
[](https://github.com/afurm) [](https://github.com/jwchmodx) [](https://github.com/leszekszpunar) [](https://github.com/Mrseenz) [](https://github.com/Yida-Dev) [](https://github.com/kesor) [](https://github.com/mazhe-nerd) [](https://github.com/buerbaumer) [](https://github.com/magimetal) [](https://github.com/patelhiren)
|
||||
|
||||
[](https://github.com/BinHPdev) [](https://github.com/RyanLee-Dev) [](https://github.com/cathrynlavery) [](https://github.com/al3mart) [](https://github.com/JustYannicc) [](https://github.com/AbhisekBasu1) [](https://github.com/dbhurley) [](https://github.com/mpz4life) [](https://github.com/tmimmanuel) [](https://github.com/JustasMonkev)
|
||||
|
||||
[](https://github.com/simantak-dabhade) [](https://github.com/NicholasSpisak) [](https://github.com/natefikru) [](https://github.com/dunamismax) [](https://github.com/simonemacario) [](https://github.com/ENCHIGO) [](https://github.com/xingsy97) [](https://github.com/emonty) [](https://github.com/jadilson12) [](https://github.com/kirisame-wang)
|
||||
|
||||
[](https://github.com/mathiasnagler) [](https://github.com/Oceanswave) [](https://github.com/gumclaw) [](https://github.com/RichardCao) [](https://github.com/MKV21) [](https://github.com/petter-b) [](https://github.com/CodeForgeNet) [](https://github.com/johnsonshi) [](https://github.com/durenzidu) [](https://github.com/dougvk)
|
||||
|
||||
[](https://github.com/Whoaa512) [](https://github.com/zimeg) [](https://github.com/TsekaLuk) [](https://github.com/Ryan-Haines) [](https://github.com/uf-hy) [](https://github.com/Daanvdplas) [](https://github.com/bittoby) [](https://github.com/xuhao1) [](https://github.com/Lucenx9) [](https://github.com/HeMuling)
|
||||
|
||||
[](https://github.com/AaronLuo00) [](https://github.com/YUJIE2002) [](https://github.com/DhruvBhatia0) [](https://github.com/divanoli) [](https://github.com/derbronko) [](https://github.com/rubyrunsstuff) [](https://github.com/rabsef-bicrym) [](https://github.com/IVY-AI-gif) [](https://github.com/pvtclawn) [](https://github.com/stephenschoettler)
|
||||
|
||||
[](https://github.com/minupla) [](https://github.com/xzq-xu) [](https://github.com/mousberg) [](https://github.com/arifahmedjoy) [](https://github.com/harhogefoo) [](https://github.com/2233admin) [](https://github.com/ameno-) [](https://github.com/battman21) [](https://github.com/bcherny) [](https://github.com/bobashopcashier)
|
||||
|
||||
[](https://github.com/dguido) [](https://github.com/druide67) [](https://github.com/guirguispierre) [](https://github.com/jzakirov) [](https://github.com/loganprit) [](https://github.com/martinfrancois) [](https://github.com/neo1027144-creator) [](https://github.com/RealKai42) [](https://github.com/schumilin) [](https://github.com/shuofengzhang)
|
||||
|
||||
[](https://github.com/solstead) [](https://github.com/hengm3467) [](https://github.com/chziyue) [](https://github.com/jameslcowan) [](https://github.com/scifantastic) [](https://github.com/ryan-crabbe) [](https://github.com/alexfilatov) [](https://github.com/Luckymingxuan) [](https://github.com/Hollychou924) [](https://github.com/badlogic)
|
||||
|
||||
[](https://github.com/hnykda) [](https://github.com/dbachelder) [](https://github.com/heavenlost) [](https://github.com/shad0wca7) [](https://github.com/jared596) [](https://github.com/kiranjd) [](https://github.com/Mellowambience) [](https://github.com/KimGLee) [](https://github.com/seheepeak) [](https://github.com/TSavo)
|
||||
|
||||
[](https://github.com/mcrolly) [](https://github.com/dashed) [](https://github.com/Shuai-DaiDai) [](https://github.com/suboss87) [](https://github.com/emanuelst) [](https://github.com/magendary) [](https://github.com/PeterShanxin) [](https://github.com/j2h4u) [](https://github.com/bsormagec) [](https://github.com/mjamiv)
|
||||
|
||||
[](https://github.com/aerolalit) [](https://github.com/jessy2027) [](https://github.com/buddyh) [](https://github.com/aaron-he-zhu) [](https://github.com/hhhhao28) [](https://github.com/benostein) [](https://github.com/LyleLiu666) [](https://github.com/pingren) [](https://github.com/popomore) [](https://github.com/Dithilli)
|
||||
|
||||
[](https://github.com/fal3) [](https://github.com/mkbehr) [](https://github.com/mteam88) [](https://github.com/gupsammy) [](https://github.com/gut-puncture) [](https://github.com/garnetlyx) [](https://github.com/miloudbelarebia) [](https://github.com/Protocol-zero-0) [](https://github.com/pvoo) [](https://github.com/patrick-yingxi-pan)
|
||||
|
||||
[](https://github.com/ptahdunbar) [](https://github.com/keepitmello) [](https://github.com/artuskg) [](https://github.com/Anandesh-Sharma) [](https://github.com/zidongdesign) [](https://github.com/Innocent-children) [](https://github.com/El-Fitz) [](https://github.com/arthurbr11) [](https://github.com/jackheuberger) [](https://github.com/serkonyc)
|
||||
|
||||
[](https://github.com/guxu11) [](https://github.com/hyojin) [](https://github.com/jeann2013) [](https://github.com/jogelin) [](https://github.com/rmorse) [](https://github.com/scz2011) [](https://github.com/andyliu) [](https://github.com/benithors) [](https://github.com/xiwuqi) [](https://github.com/TigerInYourDream)
|
||||
|
||||
[](https://github.com/aaronagent) [](https://github.com/TonyDerek-dot) [](https://github.com/Zitzak) [](https://github.com/ruypang) [](https://github.com/stainlu) [](https://github.com/OpenCils) [](https://github.com/stefangalescu) [](https://github.com/sp-hk2ldn) [](https://github.com/MikeORed) [](https://github.com/graciegould)
|
||||
|
||||
[](https://github.com/cash-echo-bot) [](https://github.com/visionik) [](https://github.com/WalterSumbon) [](https://github.com/SubtleSpark) [](https://github.com/krizpoon) [](https://github.com/rodbland2021) [](https://github.com/thomasxm) [](https://github.com/sar618) [](https://github.com/fagemx) [](https://github.com/daymade)
|
||||
|
||||
[](https://github.com/tysoncung) [](https://github.com/pycckuu) [](https://github.com/omniwired) [](https://github.com/connorshea) [](https://github.com/bonald) [](https://github.com/BeeSting50) [](https://github.com/nachoiacovino) [](https://github.com/zhumengzhu) [](https://github.com/Vitalcheffe) [](https://github.com/zhoulongchao77)
|
||||
|
||||
[](https://github.com/navarrotech) [](https://github.com/CommanderCrowCode) [](https://github.com/paceyw) [](https://github.com/Aftabbs) [](https://github.com/Alex-Alaniz) [](https://github.com/jarvis-medmatic) [](https://github.com/tomron87) [](https://github.com/day253) [](https://github.com/Jaaneek) [](https://github.com/AnCoSONG)
|
||||
|
||||
[](https://github.com/ziomancer) [](https://github.com/shayan919293) [](https://github.com/edwluo) [](https://github.com/rjchien728) [](https://github.com/TinyTb) [](https://github.com/No898) [](https://github.com/ianderrington) [](https://github.com/L-U-C-K-Y) [](https://github.com/peschee) [](https://github.com/Kepler2024)
|
||||
|
||||
[](https://github.com/julianengel) [](https://github.com/markfietje) [](https://github.com/dakshaymehta) [](https://github.com/DavidNitZ) [](https://github.com/dominicnunez) [](https://github.com/danielwanwx) [](https://github.com/hongsw) [](https://github.com/Youyou972) [](https://github.com/boris721) [](https://github.com/damoahdominic)
|
||||
|
||||
[](https://github.com/dan-dr) [](https://github.com/doodlewind) [](https://github.com/kkarimi) [](https://github.com/brokemac79) [](https://github.com/ozbillwang) [](https://github.com/ravyg) [](https://github.com/jasonhargrove) [](https://github.com/BrianWang1990) [](https://github.com/hackersifu) [](https://github.com/Fologan)
|
||||
|
||||
[](https://github.com/AnonAmit) [](https://github.com/v1p0r) [](https://github.com/ajay99511) [](https://github.com/Iranb) [](https://github.com/yhyatt) [](https://github.com/codexGW) [](https://github.com/ShaunTsai) [](https://github.com/papago2355) [](https://github.com/cdorsey) [](https://github.com/tda1017)
|
||||
|
||||
[](https://github.com/0xJonHoldsCrypto) [](https://github.com/akyourowngames) [![clawdinator[bot]](https://avatars.githubusercontent.com/in/2607181?v=4&s=48)](https://github.com/apps/clawdinator) [](https://github.com/koala73) [](https://github.com/sircrumpet) [](https://github.com/thesomewhatyou) [](https://github.com/zats) [](https://github.com/duqaXxX) [](https://github.com/Joly0) [](https://github.com/hannasdev)
|
||||
|
||||
[](https://github.com/jlowin) [](https://github.com/peetzweg) [](https://github.com/adao-max) [](https://github.com/tumf) [](https://github.com/Huntterxx) [](https://github.com/nk1tz) [](https://github.com/lidamao633) [](https://github.com/liebertar) [](https://github.com/CornBrother0x) [](https://github.com/DukeDeSouth)
|
||||
|
||||
[](https://github.com/sahancava) [](https://github.com/CashWilliams) [](https://github.com/lumpinif) [](https://github.com/AdeboyeDN) [](https://github.com/Rohan5commit) [](https://github.com/srinivaspavan9) [](https://github.com/h0tp-ftw) [](https://github.com/neooriginal) [](https://github.com/Tianworld) [](https://github.com/Bermudarat)
|
||||
|
||||
[](https://github.com/asklee-klawd) [](https://github.com/yuting0624) [](https://github.com/constansino) [](https://github.com/ghsmc) [](https://github.com/ibrahimq21) [](https://github.com/irtiq7) [](https://github.com/kelvinCB) [](https://github.com/mitsuhiko) [](https://github.com/nohat) [](https://github.com/santiagomed)
|
||||
|
||||
[](https://github.com/suminhthanh) [](https://github.com/svkozak) [](https://github.com/zhangzhefang-github) [](https://github.com/HOYALIM) [](https://github.com/ping-Toven) [](https://github.com/0-CYBERDYNE-SYSTEMS-0) [](https://github.com/ylc0919) [](https://github.com/reed1898) [](https://github.com/ItsAditya-xyz) [](https://github.com/samrusani)
|
||||
|
||||
[](https://github.com/andyk-ms) [](https://github.com/18-RAJAT) [](https://github.com/cyb1278588254) [](https://github.com/zoherghadyali) [](https://github.com/manikv12) [](https://github.com/manueltarouca) [](https://github.com/GaosCode) [](https://github.com/pahdo) [](https://github.com/detecti1) [](https://github.com/JasonOA888)
|
||||
|
||||
[](https://github.com/sumukhj1219) [](https://github.com/bakhtiersizhaev) [](https://github.com/kyleok) [](https://github.com/AkashKobal) [](https://github.com/zhuisDEV) [](https://github.com/wu-tian807) [](https://github.com/vsabavat) [](https://github.com/kinfey) [](https://github.com/crimeacs) [](https://github.com/VibhorGautam)
|
||||
|
||||
[](https://github.com/John-Rood) [](https://github.com/velamints2) [](https://github.com/benjipeng) [](https://github.com/divisonofficer) [](https://github.com/Rahulkumar070) [](https://github.com/rockcent) [](https://github.com/Limitless2023) [](https://github.com/24601) [](https://github.com/awkoy) [](https://github.com/dawondyifraw)
|
||||
|
||||
[![google-labs-jules[bot]](https://avatars.githubusercontent.com/in/842251?v=4&s=48)](https://github.com/apps/google-labs-jules) [](https://github.com/henrino3) [](https://github.com/Kansodata) [](https://github.com/kaonash) [](https://github.com/p6l-richard) [](https://github.com/pi0) [](https://github.com/skainguyen1412) [](https://github.com/Starhappysh) [](https://github.com/xdanger) [](https://github.com/p3nchan)
|
||||
|
||||
[](https://github.com/scald) [](https://github.com/kashevk0) [](https://github.com/Yuandiaodiaodiao) [](https://github.com/doguabaris) [](https://github.com/ysqander) [](https://github.com/andranik-sahakyan) [](https://github.com/Wangnov) [](https://github.com/rixau) [](https://github.com/lisitan) [](https://github.com/kaizen403)
|
||||
|
||||
[](https://github.com/hirefrank) [](https://github.com/kennyklee) [](https://github.com/dddabtc) [](https://github.com/edincampara) [](https://github.com/fellanH) [](https://github.com/VarunChopra11) [](https://github.com/wangai-studio) [](https://github.com/sleontenko) [](https://github.com/yassine20011) [](https://github.com/ant1eicher)
|
||||
|
||||
[](https://github.com/ThomsenDrake) [](https://github.com/kakuteki) [](https://github.com/andreabadesso) [](https://github.com/chenxin-yan) [](https://github.com/cordx56) [](https://github.com/dvrshil) [](https://github.com/MarvinCui) [](https://github.com/Yeom-JinHo) [](https://github.com/17jmumford) [](https://github.com/KnHack)
|
||||
|
||||
[](https://github.com/SharoonSharif) [](https://github.com/orenyomtov) [](https://github.com/mattqdev) [](https://github.com/parkertoddbrooks) [](https://github.com/he-yufeng) [](https://github.com/Milofax) [](https://github.com/stevebot-alive) [](https://github.com/zhoulf1006) [](https://github.com/jrrcdev) [](https://github.com/feniix)
|
||||
|
||||
[](https://github.com/ZetiMente) [](https://github.com/QuantDeveloperUSA) [](https://github.com/alexstyl) [](https://github.com/ethanpalm) [](https://github.com/qkal) [](https://github.com/cygaar) [](https://github.com/U-C4N) [](https://github.com/jakobdylanc) [](https://github.com/antons) [](https://github.com/austinm911)
|
||||
|
||||
[](https://github.com/mahmoudashraf93) [](https://github.com/philipp-spiess) [](https://github.com/pkrmf) [](https://github.com/joshrad-dev) [](https://github.com/factnest365-ops) [](https://github.com/yingchunbai) [](https://github.com/aj47) [](https://github.com/Alg0rix) [](https://github.com/futhgar) [](https://github.com/YonganZhang)
|
||||
|
||||
[](https://github.com/remusao) [](https://github.com/danballance) [](https://github.com/GHesericsu) [](https://github.com/kimitaka) [](https://github.com/itsjling) [](https://github.com/RayBB) [](https://github.com/lutr0) [](https://github.com/claude) [](https://github.com/angrybirddd) [](https://github.com/fabianwilliams)
|
||||
|
||||
[](https://github.com/haoruilee) [](https://github.com/8BlT) [](https://github.com/atalovesyou) [](https://github.com/erikpr1994) [](https://github.com/jonasjancarik) [](https://github.com/longmaba) [](https://github.com/mitschabaude-bot) [](https://github.com/thesash) [](https://github.com/rdev) [](https://github.com/easternbloc)
|
||||
|
||||
[](https://github.com/chrisrodz) [](https://github.com/gabriel-trigo) [](https://github.com/manmal) [](https://github.com/neist) [](https://github.com/wes-davis) [](https://github.com/ManuelHettich) [](https://github.com/sktbrd) [](https://github.com/larlyssa) [](https://github.com/pcty-nextgen-service-account) [](https://github.com/Syhids)
|
||||
|
||||
[](https://github.com/tmchow) [](https://github.com/mgratch) [](https://github.com/xtao) [](https://github.com/JackyWay) [](https://github.com/j1philli) [](https://github.com/T5-AndyML) [](https://github.com/huohua-dev) [](https://github.com/imfing) [](https://github.com/RandyVentures) [](https://github.com/marcodd23)
|
||||
|
||||
[](https://github.com/Iamadig) [](https://github.com/humanwritten) [](https://github.com/robaxelsen) [](https://github.com/prathamdby) [](https://github.com/0oAstro) [](https://github.com/aaronn) [](https://github.com/afern247) [](https://github.com/Asleep123) [](https://github.com/dantelex) [](https://github.com/fcatuhe)
|
||||
|
||||
[](https://github.com/gtsifrikas) [](https://github.com/hrdwdmrbl) [](https://github.com/hugobarauna) [](https://github.com/jayhickey) [](https://github.com/jiulingyun) [](https://github.com/jdrhyne) [](https://github.com/jverdi) [](https://github.com/kitze) [](https://github.com/loukotal) [](https://github.com/minghinmatthewlam)
|
||||
|
||||
[](https://github.com/MSch) [](https://github.com/odrobnik) [](https://github.com/oswalpalash) [](https://github.com/ratulsarna) [](https://github.com/reeltimeapps) [](https://github.com/snopoke) [](https://github.com/sreekaransrinath) [](https://github.com/timkrase)
|
||||
|
||||
<!-- clawtributors:end -->
|
||||
<!-- clawtributors:hidden:start
|
||||
default-avatar-cache: hidden from the rendered wall because these users still use GitHub's default avatar
|
||||
13otkmdr
|
||||
aaronveklabs
|
||||
adityashaw2
|
||||
ai-reviewer-qs
|
||||
alexyyyander
|
||||
alphonse-arianee
|
||||
amitbiswal007
|
||||
bbblending
|
||||
bbddbb1
|
||||
bitfoundry-ai
|
||||
bugkillerking
|
||||
carlulsoe
|
||||
charzhou
|
||||
cheeeee
|
||||
dalomeve
|
||||
danielz1z
|
||||
diaspar4u
|
||||
dirbalak
|
||||
djangonavarro220
|
||||
dobbylorenzbot
|
||||
drcrinkle
|
||||
drickon
|
||||
eddertalmor
|
||||
eengad
|
||||
efe-buken
|
||||
eric-fr4
|
||||
eronfan
|
||||
evandance
|
||||
extrasmall0
|
||||
ezhikkk
|
||||
fuller-stack-dev
|
||||
fwhite13
|
||||
gambletan
|
||||
gejifeng
|
||||
harrington-bot
|
||||
heimdallstrategy
|
||||
heyhudson
|
||||
hougangdev
|
||||
jamesgroat
|
||||
jamtujest
|
||||
jaymishra-source
|
||||
joe2643
|
||||
joetomasone
|
||||
jonathanworks
|
||||
jonisjongithub
|
||||
jscaldwell55
|
||||
julbarth
|
||||
junjunjunbong
|
||||
kirillshchetinin
|
||||
kyohwang
|
||||
lailoo
|
||||
latitudeki5223
|
||||
lawrence3699
|
||||
liaosvcaf
|
||||
livingghost
|
||||
luijoc
|
||||
lukeboyett
|
||||
lurebat
|
||||
mahanandhi
|
||||
maple778
|
||||
martingarramon
|
||||
matthew19990919
|
||||
moktamd
|
||||
moltbot886
|
||||
mujiannan
|
||||
mukhtharcm
|
||||
mylszd
|
||||
natedenh
|
||||
nicholascyh
|
||||
nickhood1984
|
||||
nico-hoff
|
||||
nikus-pan
|
||||
nonggialiang
|
||||
oliviareid-svg
|
||||
openclaw-bot
|
||||
pablohrcarvalho
|
||||
patrick-barletta
|
||||
pinghuachiu
|
||||
private-peter
|
||||
prospectore
|
||||
rafaelreis-r
|
||||
rexl2018
|
||||
rexlunae
|
||||
rhjoh
|
||||
ronak-guliani
|
||||
ryancontent
|
||||
ryanngit
|
||||
rybnikov
|
||||
sandpile
|
||||
sbking
|
||||
shivamraut101
|
||||
shuicici
|
||||
slats24
|
||||
slepybear
|
||||
sline
|
||||
socialnerd42069
|
||||
solodmd
|
||||
sudie-codes
|
||||
sumleo
|
||||
superman32432432
|
||||
ted-developer
|
||||
tempeste
|
||||
theonejvo
|
||||
tosh-hamburg
|
||||
uli-will-code
|
||||
w-sss
|
||||
whiskyboy
|
||||
wittam-01
|
||||
xieyongliang
|
||||
yassinebkr
|
||||
yuna78
|
||||
yuweuii
|
||||
yxjsxy
|
||||
zijiess
|
||||
clawtributors:hidden:end -->
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/vincentkoc"><img src="https://avatars.githubusercontent.com/u/25068?v=4&s=48" width="48" height="48" alt="vincentkoc" title="vincentkoc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/mcaxtr"><img src="https://avatars.githubusercontent.com/u/7562095?v=4&s=48" width="48" height="48" alt="mcaxtr" title="mcaxtr"/></a> <a href="https://github.com/quotentiroler"><img src="https://avatars.githubusercontent.com/u/40643627?v=4&s=48" width="48" height="48" alt="quotentiroler" title="quotentiroler"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/Sid-Qin"><img src="https://avatars.githubusercontent.com/u/201593046?v=4&s=48" width="48" height="48" alt="Sid-Qin" title="Sid-Qin"/></a> <a href="https://github.com/joshavant"><img src="https://avatars.githubusercontent.com/u/830519?v=4&s=48" width="48" height="48" alt="joshavant" title="joshavant"/></a> <a href="https://github.com/shakkernerd"><img src="https://avatars.githubusercontent.com/u/165377636?v=4&s=48" width="48" height="48" alt="shakkernerd" title="shakkernerd"/></a> <a href="https://github.com/bmendonca3"><img src="https://avatars.githubusercontent.com/u/208517100?v=4&s=48" width="48" height="48" alt="bmendonca3" title="bmendonca3"/></a>
|
||||
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/lailoo"><img src="https://avatars.githubusercontent.com/u/20536249?v=4&s=48" width="48" height="48" alt="lailoo" title="lailoo"/></a> <a href="https://github.com/arosstale"><img src="https://avatars.githubusercontent.com/u/117890364?v=4&s=48" width="48" height="48" alt="arosstale" title="arosstale"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/0xRaini"><img src="https://avatars.githubusercontent.com/u/190923101?v=4&s=48" width="48" height="48" alt="Elonito" title="Elonito"/></a> <a href="https://github.com/Clawborn"><img src="https://avatars.githubusercontent.com/u/261310391?v=4&s=48" width="48" height="48" alt="Clawborn" title="Clawborn"/></a>
|
||||
<a href="https://github.com/yinghaosang"><img src="https://avatars.githubusercontent.com/u/261132136?v=4&s=48" width="48" height="48" alt="yinghaosang" title="yinghaosang"/></a> <a href="https://github.com/BunsDev"><img src="https://avatars.githubusercontent.com/u/68980965?v=4&s=48" width="48" height="48" alt="BunsDev" title="BunsDev"/></a> <a href="https://github.com/christianklotz"><img src="https://avatars.githubusercontent.com/u/69443?v=4&s=48" width="48" height="48" alt="christianklotz" title="christianklotz"/></a> <a href="https://github.com/echoVic"><img src="https://avatars.githubusercontent.com/u/16428813?v=4&s=48" width="48" height="48" alt="echoVic" title="echoVic"/></a> <a href="https://github.com/coygeek"><img src="https://avatars.githubusercontent.com/u/65363919?v=4&s=48" width="48" height="48" alt="coygeek" title="coygeek"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a>
|
||||
<a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/VeriteIgiraneza"><img src="https://avatars.githubusercontent.com/u/69280208?v=4&s=48" width="48" height="48" alt="Verite Igiraneza" title="Verite Igiraneza"/></a> <a href="https://github.com/widingmarcus-cyber"><img src="https://avatars.githubusercontent.com/u/245375637?v=4&s=48" width="48" height="48" alt="widingmarcus-cyber" title="widingmarcus-cyber"/></a> <a href="https://github.com/akramcodez"><img src="https://avatars.githubusercontent.com/u/179671552?v=4&s=48" width="48" height="48" alt="akramcodez" title="akramcodez"/></a> <a href="https://github.com/aether-ai-agent"><img src="https://avatars.githubusercontent.com/u/261339948?v=4&s=48" width="48" height="48" alt="aether-ai-agent" title="aether-ai-agent"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chilu18"><img src="https://avatars.githubusercontent.com/u/7957943?v=4&s=48" width="48" height="48" alt="chilu18" title="chilu18"/></a> <a href="https://github.com/byungsker"><img src="https://avatars.githubusercontent.com/u/72309817?v=4&s=48" width="48" height="48" alt="byungsker" title="byungsker"/></a>
|
||||
<a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/JayMishra-source"><img src="https://avatars.githubusercontent.com/u/82963117?v=4&s=48" width="48" height="48" alt="JayMishra-source" title="JayMishra-source"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/mudrii"><img src="https://avatars.githubusercontent.com/u/220262?v=4&s=48" width="48" height="48" alt="mudrii" title="mudrii"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/Solvely-Colin"><img src="https://avatars.githubusercontent.com/u/211764741?v=4&s=48" width="48" height="48" alt="Solvely-Colin" title="Solvely-Colin"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/advaitpaliwal"><img src="https://avatars.githubusercontent.com/u/66044327?v=4&s=48" width="48" height="48" alt="advaitpaliwal" title="advaitpaliwal"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
|
||||
<a href="https://github.com/HenryLoenwind"><img src="https://avatars.githubusercontent.com/u/1485873?v=4&s=48" width="48" height="48" alt="HenryLoenwind" title="HenryLoenwind"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/Lukavyi"><img src="https://avatars.githubusercontent.com/u/1013690?v=4&s=48" width="48" height="48" alt="Lukavyi" title="Lukavyi"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/brandonwise"><img src="https://avatars.githubusercontent.com/u/21148772?v=4&s=48" width="48" height="48" alt="brandonwise" title="brandonwise"/></a> <a href="https://github.com/conroywhitney"><img src="https://avatars.githubusercontent.com/u/249891?v=4&s=48" width="48" height="48" alt="conroywhitney" title="conroywhitney"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/davidrudduck"><img src="https://avatars.githubusercontent.com/u/47308254?v=4&s=48" width="48" height="48" alt="davidrudduck" title="davidrudduck"/></a> <a href="https://github.com/xinhuagu"><img src="https://avatars.githubusercontent.com/u/562450?v=4&s=48" width="48" height="48" alt="xinhuagu" title="xinhuagu"/></a> <a href="https://github.com/jaydenfyi"><img src="https://avatars.githubusercontent.com/u/213395523?v=4&s=48" width="48" height="48" alt="jaydenfyi" title="jaydenfyi"/></a>
|
||||
<a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/heyhudson"><img src="https://avatars.githubusercontent.com/u/258693705?v=4&s=48" width="48" height="48" alt="heyhudson" title="heyhudson"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/huntharo"><img src="https://avatars.githubusercontent.com/u/5617868?v=4&s=48" width="48" height="48" alt="huntharo" title="huntharo"/></a> <a href="https://github.com/omair445"><img src="https://avatars.githubusercontent.com/u/32237905?v=4&s=48" width="48" height="48" alt="omair445" title="omair445"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/adhitShet"><img src="https://avatars.githubusercontent.com/u/131381638?v=4&s=48" width="48" height="48" alt="adhitShet" title="adhitShet"/></a> <a href="https://github.com/smartprogrammer93"><img src="https://avatars.githubusercontent.com/u/33181301?v=4&s=48" width="48" height="48" alt="smartprogrammer93" title="smartprogrammer93"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/frankekn"><img src="https://avatars.githubusercontent.com/u/4488090?v=4&s=48" width="48" height="48" alt="frankekn" title="frankekn"/></a>
|
||||
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/shadril238"><img src="https://avatars.githubusercontent.com/u/63901551?v=4&s=48" width="48" height="48" alt="shadril238" title="shadril238"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/stakeswky"><img src="https://avatars.githubusercontent.com/u/64798754?v=4&s=48" width="48" height="48" alt="stakeswky" title="stakeswky"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/MisterGuy420"><img src="https://avatars.githubusercontent.com/u/255743668?v=4&s=48" width="48" height="48" alt="MisterGuy420" title="MisterGuy420"/></a>
|
||||
<a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/nabbilkhan"><img src="https://avatars.githubusercontent.com/u/203121263?v=4&s=48" width="48" height="48" alt="nabbilkhan" title="nabbilkhan"/></a> <a href="https://github.com/aldoeliacim"><img src="https://avatars.githubusercontent.com/u/17973757?v=4&s=48" width="48" height="48" alt="aldoeliacim" title="aldoeliacim"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/orlyjamie"><img src="https://avatars.githubusercontent.com/u/6668807?v=4&s=48" width="48" height="48" alt="orlyjamie" title="orlyjamie"/></a> <a href="https://github.com/Elarwei001"><img src="https://avatars.githubusercontent.com/u/168552401?v=4&s=48" width="48" height="48" alt="Elarwei001" title="Elarwei001"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/Phineas1500"><img src="https://avatars.githubusercontent.com/u/41450967?v=4&s=48" width="48" height="48" alt="Phineas1500" title="Phineas1500"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/sfo2001"><img src="https://avatars.githubusercontent.com/u/103369858?v=4&s=48" width="48" height="48" alt="sfo2001" title="sfo2001"/></a>
|
||||
<a href="https://github.com/Marvae"><img src="https://avatars.githubusercontent.com/u/11957602?v=4&s=48" width="48" height="48" alt="Marvae" title="Marvae"/></a> <a href="https://github.com/liuy"><img src="https://avatars.githubusercontent.com/u/1192888?v=4&s=48" width="48" height="48" alt="liuy" title="liuy"/></a> <a href="https://github.com/shtse8"><img src="https://avatars.githubusercontent.com/u/8020099?v=4&s=48" width="48" height="48" alt="shtse8" title="shtse8"/></a> <a href="https://github.com/thebenignhacker"><img src="https://avatars.githubusercontent.com/u/32418586?v=4&s=48" width="48" height="48" alt="thebenignhacker" title="thebenignhacker"/></a> <a href="https://github.com/carrotRakko"><img src="https://avatars.githubusercontent.com/u/24588751?v=4&s=48" width="48" height="48" alt="carrotRakko" title="carrotRakko"/></a> <a href="https://github.com/ranausmanai"><img src="https://avatars.githubusercontent.com/u/257128159?v=4&s=48" width="48" height="48" alt="ranausmanai" title="ranausmanai"/></a> <a href="https://github.com/kevinWangSheng"><img src="https://avatars.githubusercontent.com/u/118158941?v=4&s=48" width="48" height="48" alt="kevinWangSheng" title="kevinWangSheng"/></a> <a href="https://github.com/gregmousseau"><img src="https://avatars.githubusercontent.com/u/5036458?v=4&s=48" width="48" height="48" alt="gregmousseau" title="gregmousseau"/></a> <a href="https://github.com/rrenamed"><img src="https://avatars.githubusercontent.com/u/87486610?v=4&s=48" width="48" height="48" alt="rrenamed" title="rrenamed"/></a> <a href="https://github.com/akoscz"><img src="https://avatars.githubusercontent.com/u/1360047?v=4&s=48" width="48" height="48" alt="akoscz" title="akoscz"/></a>
|
||||
<a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/pandego"><img src="https://avatars.githubusercontent.com/u/7780875?v=4&s=48" width="48" height="48" alt="pandego" title="pandego"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/graysurf"><img src="https://avatars.githubusercontent.com/u/10785178?v=4&s=48" width="48" height="48" alt="graysurf" title="graysurf"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/nyanjou"><img src="https://avatars.githubusercontent.com/u/258645604?v=4&s=48" width="48" height="48" alt="nyanjou" title="nyanjou"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/gejifeng"><img src="https://avatars.githubusercontent.com/u/17561857?v=4&s=48" width="48" height="48" alt="gejifeng" title="gejifeng"/></a>
|
||||
<a href="https://github.com/ide-rea"><img src="https://avatars.githubusercontent.com/u/30512600?v=4&s=48" width="48" height="48" alt="ide-rea" title="ide-rea"/></a> <a href="https://github.com/leszekszpunar"><img src="https://avatars.githubusercontent.com/u/13106764?v=4&s=48" width="48" height="48" alt="leszekszpunar" title="leszekszpunar"/></a> <a href="https://github.com/Yida-Dev"><img src="https://avatars.githubusercontent.com/u/92713555?v=4&s=48" width="48" height="48" alt="Yida-Dev" title="Yida-Dev"/></a> <a href="https://github.com/AI-Reviewer-QS"><img src="https://avatars.githubusercontent.com/u/255312808?v=4&s=48" width="48" height="48" alt="AI-Reviewer-QS" title="AI-Reviewer-QS"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/hougangdev"><img src="https://avatars.githubusercontent.com/u/105773686?v=4&s=48" width="48" height="48" alt="hougangdev" title="hougangdev"/></a> <a href="https://github.com/Minidoracat"><img src="https://avatars.githubusercontent.com/u/11269639?v=4&s=48" width="48" height="48" alt="Minidoracat" title="Minidoracat"/></a> <a href="https://github.com/AnonO6"><img src="https://avatars.githubusercontent.com/u/124311066?v=4&s=48" width="48" height="48" alt="AnonO6" title="AnonO6"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a>
|
||||
<a href="https://github.com/YuzuruS"><img src="https://avatars.githubusercontent.com/u/1485195?v=4&s=48" width="48" height="48" alt="YuzuruS" title="YuzuruS"/></a> <a href="https://github.com/riccardogiorato"><img src="https://avatars.githubusercontent.com/u/4527364?v=4&s=48" width="48" height="48" alt="riccardogiorato" title="riccardogiorato"/></a> <a href="https://github.com/Bridgerz"><img src="https://avatars.githubusercontent.com/u/24499532?v=4&s=48" width="48" height="48" alt="Bridgerz" title="Bridgerz"/></a> <a href="https://github.com/Mrseenz"><img src="https://avatars.githubusercontent.com/u/101962919?v=4&s=48" width="48" height="48" alt="Mrseenz" title="Mrseenz"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a>
|
||||
<a href="https://github.com/buerbaumer"><img src="https://avatars.githubusercontent.com/u/44548809?v=4&s=48" width="48" height="48" alt="Harald Buerbaumer" title="Harald Buerbaumer"/></a> <a href="https://github.com/taw0002"><img src="https://avatars.githubusercontent.com/u/42811278?v=4&s=48" width="48" height="48" alt="taw0002" title="taw0002"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/openperf"><img src="https://avatars.githubusercontent.com/u/80630709?v=4&s=48" width="48" height="48" alt="openperf" title="openperf"/></a> <a href="https://github.com/BUGKillerKing"><img src="https://avatars.githubusercontent.com/u/117326392?v=4&s=48" width="48" height="48" alt="BUGKillerKing" title="BUGKillerKing"/></a> <a href="https://github.com/Oceanswave"><img src="https://avatars.githubusercontent.com/u/760674?v=4&s=48" width="48" height="48" alt="Oceanswave" title="Oceanswave"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="Hiren Patel" title="Hiren Patel"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
||||
<a href="https://github.com/jadilson12"><img src="https://avatars.githubusercontent.com/u/36805474?v=4&s=48" width="48" height="48" alt="jadilson12" title="jadilson12"/></a> <a href="https://github.com/sumleo"><img src="https://avatars.githubusercontent.com/u/29517764?v=4&s=48" width="48" height="48" alt="sumleo" title="sumleo"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/luijoc"><img src="https://avatars.githubusercontent.com/u/96428056?v=4&s=48" width="48" height="48" alt="luijoc" title="luijoc"/></a> <a href="https://github.com/niceysam"><img src="https://avatars.githubusercontent.com/u/256747835?v=4&s=48" width="48" height="48" alt="niceysam" title="niceysam"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/TsekaLuk"><img src="https://avatars.githubusercontent.com/u/79151285?v=4&s=48" width="48" height="48" alt="TsekaLuk" title="TsekaLuk"/></a> <a href="https://github.com/JustasMonkev"><img src="https://avatars.githubusercontent.com/u/59362982?v=4&s=48" width="48" height="48" alt="JustasM" title="JustasM"/></a> <a href="https://github.com/loiie45e"><img src="https://avatars.githubusercontent.com/u/15420100?v=4&s=48" width="48" height="48" alt="loiie45e" title="loiie45e"/></a>
|
||||
<a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/natefikru"><img src="https://avatars.githubusercontent.com/u/10344644?v=4&s=48" width="48" height="48" alt="natefikru" title="natefikru"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/simonemacario"><img src="https://avatars.githubusercontent.com/u/2116609?v=4&s=48" width="48" height="48" alt="Simone Macario" title="Simone Macario"/></a> <a href="https://github.com/openclaw-bot"><img src="https://avatars.githubusercontent.com/u/258178069?v=4&s=48" width="48" height="48" alt="openclaw-bot" title="openclaw-bot"/></a> <a href="https://github.com/ENCHIGO"><img src="https://avatars.githubusercontent.com/u/38551565?v=4&s=48" width="48" height="48" alt="ENCHIGO" title="ENCHIGO"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
|
||||
<a href="https://github.com/Blakeshannon"><img src="https://avatars.githubusercontent.com/u/257822860?v=4&s=48" width="48" height="48" alt="Blakeshannon" title="Blakeshannon"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/pejmanjohn"><img src="https://avatars.githubusercontent.com/u/481729?v=4&s=48" width="48" height="48" alt="pejmanjohn" title="pejmanjohn"/></a> <a href="https://github.com/durenzidu"><img src="https://avatars.githubusercontent.com/u/38130340?v=4&s=48" width="48" height="48" alt="durenzidu" title="durenzidu"/></a> <a href="https://github.com/Ryan-Haines"><img src="https://avatars.githubusercontent.com/u/1855752?v=4&s=48" width="48" height="48" alt="Ryan Haines" title="Ryan Haines"/></a> <a href="https://github.com/hclsys"><img src="https://avatars.githubusercontent.com/u/7755017?v=4&s=48" width="48" height="48" alt="hcl" title="hcl"/></a> <a href="https://github.com/xuhao1"><img src="https://avatars.githubusercontent.com/u/5087930?v=4&s=48" width="48" height="48" alt="XuHao" title="XuHao"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bitfoundry-ai"><img src="https://avatars.githubusercontent.com/u/239082898?v=4&s=48" width="48" height="48" alt="bitfoundry-ai" title="bitfoundry-ai"/></a>
|
||||
<a href="https://github.com/HeMuling"><img src="https://avatars.githubusercontent.com/u/74801533?v=4&s=48" width="48" height="48" alt="HeMuling" title="HeMuling"/></a> <a href="https://github.com/markmusson"><img src="https://avatars.githubusercontent.com/u/4801649?v=4&s=48" width="48" height="48" alt="markmusson" title="markmusson"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/battman21"><img src="https://avatars.githubusercontent.com/u/2656916?v=4&s=48" width="48" height="48" alt="battman21" title="battman21"/></a> <a href="https://github.com/BinHPdev"><img src="https://avatars.githubusercontent.com/u/219093083?v=4&s=48" width="48" height="48" alt="BinHPdev" title="BinHPdev"/></a> <a href="https://github.com/dguido"><img src="https://avatars.githubusercontent.com/u/294844?v=4&s=48" width="48" height="48" alt="dguido" title="dguido"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/guirguispierre"><img src="https://avatars.githubusercontent.com/u/22091706?v=4&s=48" width="48" height="48" alt="guirguispierre" title="guirguispierre"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/joeykrug"><img src="https://avatars.githubusercontent.com/u/5925937?v=4&s=48" width="48" height="48" alt="joeykrug" title="joeykrug"/></a>
|
||||
<a href="https://github.com/loganprit"><img src="https://avatars.githubusercontent.com/u/72722788?v=4&s=48" width="48" height="48" alt="loganprit" title="loganprit"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/dbachelder"><img src="https://avatars.githubusercontent.com/u/325706?v=4&s=48" width="48" height="48" alt="dbachelder" title="dbachelder"/></a> <a href="https://github.com/divanoli"><img src="https://avatars.githubusercontent.com/u/12023205?v=4&s=48" width="48" height="48" alt="Divanoli Mydeen Pitchai" title="Divanoli Mydeen Pitchai"/></a> <a href="https://github.com/liuxiaopai-ai"><img src="https://avatars.githubusercontent.com/u/73659136?v=4&s=48" width="48" height="48" alt="liuxiaopai-ai" title="liuxiaopai-ai"/></a> <a href="https://github.com/theSamPadilla"><img src="https://avatars.githubusercontent.com/u/35386211?v=4&s=48" width="48" height="48" alt="Sam Padilla" title="Sam Padilla"/></a> <a href="https://github.com/pvtclawn"><img src="https://avatars.githubusercontent.com/u/258811507?v=4&s=48" width="48" height="48" alt="pvtclawn" title="pvtclawn"/></a> <a href="https://github.com/seheepeak"><img src="https://avatars.githubusercontent.com/u/134766597?v=4&s=48" width="48" height="48" alt="seheepeak" title="seheepeak"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a>
|
||||
<a href="https://github.com/misterdas"><img src="https://avatars.githubusercontent.com/u/170702047?v=4&s=48" width="48" height="48" alt="misterdas" title="misterdas"/></a> <a href="https://github.com/xzq-xu"><img src="https://avatars.githubusercontent.com/u/53989315?v=4&s=48" width="48" height="48" alt="LeftX" title="LeftX"/></a> <a href="https://github.com/badlogic"><img src="https://avatars.githubusercontent.com/u/514052?v=4&s=48" width="48" height="48" alt="badlogic" title="badlogic"/></a> <a href="https://github.com/Shuai-DaiDai"><img src="https://avatars.githubusercontent.com/u/134567396?v=4&s=48" width="48" height="48" alt="Shuai-DaiDai" title="Shuai-DaiDai"/></a> <a href="https://github.com/mousberg"><img src="https://avatars.githubusercontent.com/u/57605064?v=4&s=48" width="48" height="48" alt="mousberg" title="mousberg"/></a> <a href="https://github.com/harhogefoo"><img src="https://avatars.githubusercontent.com/u/11906529?v=4&s=48" width="48" height="48" alt="Masataka Shinohara" title="Masataka Shinohara"/></a> <a href="https://github.com/BillChirico"><img src="https://avatars.githubusercontent.com/u/13951316?v=4&s=48" width="48" height="48" alt="BillChirico" title="BillChirico"/></a> <a href="https://github.com/lewiswigmore"><img src="https://avatars.githubusercontent.com/u/58551848?v=4&s=48" width="48" height="48" alt="Lewis" title="Lewis"/></a> <a href="https://github.com/solstead"><img src="https://avatars.githubusercontent.com/u/168413654?v=4&s=48" width="48" height="48" alt="solstead" title="solstead"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a>
|
||||
<a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/sahilsatralkar"><img src="https://avatars.githubusercontent.com/u/62758655?v=4&s=48" width="48" height="48" alt="sahilsatralkar" title="sahilsatralkar"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/ryan-crabbe"><img src="https://avatars.githubusercontent.com/u/128659760?v=4&s=48" width="48" height="48" alt="ryan-crabbe" title="ryan-crabbe"/></a> <a href="https://github.com/miloudbelarebia"><img src="https://avatars.githubusercontent.com/u/136994453?v=4&s=48" width="48" height="48" alt="miloudbelarebia" title="miloudbelarebia"/></a> <a href="https://github.com/Mellowambience"><img src="https://avatars.githubusercontent.com/u/40958792?v=4&s=48" width="48" height="48" alt="Mars" title="Mars"/></a> <a href="https://github.com/El-Fitz"><img src="https://avatars.githubusercontent.com/u/8971906?v=4&s=48" width="48" height="48" alt="El-Fitz" title="El-Fitz"/></a> <a href="https://github.com/mcrolly"><img src="https://avatars.githubusercontent.com/u/60803337?v=4&s=48" width="48" height="48" alt="McRolly NWANGWU" title="McRolly NWANGWU"/></a>
|
||||
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/Dithilli"><img src="https://avatars.githubusercontent.com/u/41286037?v=4&s=48" width="48" height="48" alt="Dithilli" title="Dithilli"/></a> <a href="https://github.com/emonty"><img src="https://avatars.githubusercontent.com/u/95156?v=4&s=48" width="48" height="48" alt="emonty" title="emonty"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/benostein"><img src="https://avatars.githubusercontent.com/u/31802821?v=4&s=48" width="48" height="48" alt="benostein" title="benostein"/></a> <a href="https://github.com/PeterShanxin"><img src="https://avatars.githubusercontent.com/u/128674037?v=4&s=48" width="48" height="48" alt="LI SHANXIN" title="LI SHANXIN"/></a> <a href="https://github.com/magendary"><img src="https://avatars.githubusercontent.com/u/30611068?v=4&s=48" width="48" height="48" alt="magendary" title="magendary"/></a> <a href="https://github.com/mahanandhi"><img src="https://avatars.githubusercontent.com/u/46371575?v=4&s=48" width="48" height="48" alt="mahanandhi" title="mahanandhi"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||
<a href="https://github.com/j2h4u"><img src="https://avatars.githubusercontent.com/u/39818683?v=4&s=48" width="48" height="48" alt="j2h4u" title="j2h4u"/></a> <a href="https://github.com/bsormagec"><img src="https://avatars.githubusercontent.com/u/965219?v=4&s=48" width="48" height="48" alt="bsormagec" title="bsormagec"/></a> <a href="https://github.com/jessy2027"><img src="https://avatars.githubusercontent.com/u/89694096?v=4&s=48" width="48" height="48" alt="Jessy LANGE" title="Jessy LANGE"/></a> <a href="https://github.com/aerolalit"><img src="https://avatars.githubusercontent.com/u/17166039?v=4&s=48" width="48" height="48" alt="Lalit Singh" title="Lalit Singh"/></a> <a href="https://github.com/hyf0-agent"><img src="https://avatars.githubusercontent.com/u/258783736?v=4&s=48" width="48" height="48" alt="hyf0-agent" title="hyf0-agent"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/unisone"><img src="https://avatars.githubusercontent.com/u/32521398?v=4&s=48" width="48" height="48" alt="unisone" title="unisone"/></a> <a href="https://github.com/jeann2013"><img src="https://avatars.githubusercontent.com/u/3299025?v=4&s=48" width="48" height="48" alt="jeann2013" title="jeann2013"/></a> <a href="https://github.com/jogelin"><img src="https://avatars.githubusercontent.com/u/954509?v=4&s=48" width="48" height="48" alt="jogelin" title="jogelin"/></a> <a href="https://github.com/rmorse"><img src="https://avatars.githubusercontent.com/u/853547?v=4&s=48" width="48" height="48" alt="rmorse" title="rmorse"/></a>
|
||||
<a href="https://github.com/scz2011"><img src="https://avatars.githubusercontent.com/u/9337506?v=4&s=48" width="48" height="48" alt="scz2011" title="scz2011"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/popomore"><img src="https://avatars.githubusercontent.com/u/360661?v=4&s=48" width="48" height="48" alt="popomore" title="popomore"/></a> <a href="https://github.com/cathrynlavery"><img src="https://avatars.githubusercontent.com/u/50469282?v=4&s=48" width="48" height="48" alt="cathrynlavery" title="cathrynlavery"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/jscaldwell55"><img src="https://avatars.githubusercontent.com/u/111952840?v=4&s=48" width="48" height="48" alt="Jay Caldwell" title="Jay Caldwell"/></a> <a href="https://github.com/gut-puncture"><img src="https://avatars.githubusercontent.com/u/75851986?v=4&s=48" width="48" height="48" alt="Shailesh" title="Shailesh"/></a> <a href="https://github.com/KirillShchetinin"><img src="https://avatars.githubusercontent.com/u/13061871?v=4&s=48" width="48" height="48" alt="Kirill Shchetynin" title="Kirill Shchetynin"/></a> <a href="https://github.com/ruypang"><img src="https://avatars.githubusercontent.com/u/46941315?v=4&s=48" width="48" height="48" alt="ruypang" title="ruypang"/></a>
|
||||
<a href="https://github.com/mitchmcalister"><img src="https://avatars.githubusercontent.com/u/209334?v=4&s=48" width="48" height="48" alt="mitchmcalister" title="mitchmcalister"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="Paul van Oorschot" title="Paul van Oorschot"/></a> <a href="https://github.com/guxu11"><img src="https://avatars.githubusercontent.com/u/53551744?v=4&s=48" width="48" height="48" alt="Xu Gu" title="Xu Gu"/></a> <a href="https://github.com/lml2468"><img src="https://avatars.githubusercontent.com/u/39320777?v=4&s=48" width="48" height="48" alt="Menglin Li" title="Menglin Li"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/jackheuberger"><img src="https://avatars.githubusercontent.com/u/7830838?v=4&s=48" width="48" height="48" alt="jackheuberger" title="jackheuberger"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/Zitzak"><img src="https://avatars.githubusercontent.com/u/43185740?v=4&s=48" width="48" height="48" alt="Marvin" title="Marvin"/></a>
|
||||
<a href="https://github.com/DrCrinkle"><img src="https://avatars.githubusercontent.com/u/62564740?v=4&s=48" width="48" height="48" alt="Taylor Asplund" title="Taylor Asplund"/></a> <a href="https://github.com/dakshaymehta"><img src="https://avatars.githubusercontent.com/u/50276213?v=4&s=48" width="48" height="48" alt="dakshaymehta" title="dakshaymehta"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="Stefan Galescu" title="Stefan Galescu"/></a> <a href="https://github.com/lploc94"><img src="https://avatars.githubusercontent.com/u/28453843?v=4&s=48" width="48" height="48" alt="lploc94" title="lploc94"/></a> <a href="https://github.com/WalterSumbon"><img src="https://avatars.githubusercontent.com/u/45062253?v=4&s=48" width="48" height="48" alt="WalterSumbon" title="WalterSumbon"/></a> <a href="https://github.com/krizpoon"><img src="https://avatars.githubusercontent.com/u/1977532?v=4&s=48" width="48" height="48" alt="krizpoon" title="krizpoon"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/Grynn"><img src="https://avatars.githubusercontent.com/u/212880?v=4&s=48" width="48" height="48" alt="Grynn" title="Grynn"/></a> <a href="https://github.com/hydro13"><img src="https://avatars.githubusercontent.com/u/6640526?v=4&s=48" width="48" height="48" alt="hydro13" title="hydro13"/></a>
|
||||
<a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/kentaro"><img src="https://avatars.githubusercontent.com/u/3458?v=4&s=48" width="48" height="48" alt="kentaro" title="kentaro"/></a> <a href="https://github.com/kunalk16"><img src="https://avatars.githubusercontent.com/u/5303824?v=4&s=48" width="48" height="48" alt="kunalk16" title="kunalk16"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/optimikelabs"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="optimikelabs" title="optimikelabs"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/RamiNoodle733"><img src="https://avatars.githubusercontent.com/u/117773986?v=4&s=48" width="48" height="48" alt="RamiNoodle733" title="RamiNoodle733"/></a> <a href="https://github.com/sauerdaniel"><img src="https://avatars.githubusercontent.com/u/81422812?v=4&s=48" width="48" height="48" alt="sauerdaniel" title="sauerdaniel"/></a> <a href="https://github.com/SleuthCo"><img src="https://avatars.githubusercontent.com/u/259695222?v=4&s=48" width="48" height="48" alt="SleuthCo" title="SleuthCo"/></a>
|
||||
<a href="https://github.com/TaKO8Ki"><img src="https://avatars.githubusercontent.com/u/41065217?v=4&s=48" width="48" height="48" alt="TaKO8Ki" title="TaKO8Ki"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/rodbland2021"><img src="https://avatars.githubusercontent.com/u/86267410?v=4&s=48" width="48" height="48" alt="rodbland2021" title="rodbland2021"/></a> <a href="https://github.com/fagemx"><img src="https://avatars.githubusercontent.com/u/117356295?v=4&s=48" width="48" height="48" alt="fagemx" title="fagemx"/></a> <a href="https://github.com/BigUncle"><img src="https://avatars.githubusercontent.com/u/9360607?v=4&s=48" width="48" height="48" alt="BigUncle" title="BigUncle"/></a> <a href="https://github.com/pycckuu"><img src="https://avatars.githubusercontent.com/u/1489583?v=4&s=48" width="48" height="48" alt="Igor Markelov" title="Igor Markelov"/></a> <a href="https://github.com/zhoulongchao77"><img src="https://avatars.githubusercontent.com/u/65058500?v=4&s=48" width="48" height="48" alt="zhoulc777" title="zhoulc777"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/paceyw"><img src="https://avatars.githubusercontent.com/u/44923937?v=4&s=48" width="48" height="48" alt="TIHU" title="TIHU"/></a> <a href="https://github.com/tonydehnke"><img src="https://avatars.githubusercontent.com/u/36720180?v=4&s=48" width="48" height="48" alt="Tony Dehnke" title="Tony Dehnke"/></a>
|
||||
<a href="https://github.com/pablohrcarvalho"><img src="https://avatars.githubusercontent.com/u/66948122?v=4&s=48" width="48" height="48" alt="pablohrcarvalho" title="pablohrcarvalho"/></a> <a href="https://github.com/bonald"><img src="https://avatars.githubusercontent.com/u/12394874?v=4&s=48" width="48" height="48" alt="bonald" title="bonald"/></a> <a href="https://github.com/rhuanssauro"><img src="https://avatars.githubusercontent.com/u/164682191?v=4&s=48" width="48" height="48" alt="rhuanssauro" title="rhuanssauro"/></a> <a href="https://github.com/CommanderCrowCode"><img src="https://avatars.githubusercontent.com/u/72845369?v=4&s=48" width="48" height="48" alt="Tanwa Arpornthip" title="Tanwa Arpornthip"/></a> <a href="https://github.com/webvijayi"><img src="https://avatars.githubusercontent.com/u/49924855?v=4&s=48" width="48" height="48" alt="webvijayi" title="webvijayi"/></a> <a href="https://github.com/tomron87"><img src="https://avatars.githubusercontent.com/u/126325152?v=4&s=48" width="48" height="48" alt="Tom Ron" title="Tom Ron"/></a> <a href="https://github.com/ozbillwang"><img src="https://avatars.githubusercontent.com/u/8954908?v=4&s=48" width="48" height="48" alt="ozbillwang" title="ozbillwang"/></a> <a href="https://github.com/Patrick-Barletta"><img src="https://avatars.githubusercontent.com/u/67929313?v=4&s=48" width="48" height="48" alt="Patrick Barletta" title="Patrick Barletta"/></a> <a href="https://github.com/ianderrington"><img src="https://avatars.githubusercontent.com/u/76016868?v=4&s=48" width="48" height="48" alt="Ian Derrington" title="Ian Derrington"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a>
|
||||
<a href="https://github.com/Ayush10"><img src="https://avatars.githubusercontent.com/u/7945279?v=4&s=48" width="48" height="48" alt="Ayush10" title="Ayush10"/></a> <a href="https://github.com/boris721"><img src="https://avatars.githubusercontent.com/u/257853888?v=4&s=48" width="48" height="48" alt="boris721" title="boris721"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/doodlewind"><img src="https://avatars.githubusercontent.com/u/7312949?v=4&s=48" width="48" height="48" alt="doodlewind" title="doodlewind"/></a> <a href="https://github.com/ikari-pl"><img src="https://avatars.githubusercontent.com/u/811702?v=4&s=48" width="48" height="48" alt="ikari-pl" title="ikari-pl"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/shayan919293"><img src="https://avatars.githubusercontent.com/u/60409704?v=4&s=48" width="48" height="48" alt="shayan919293" title="shayan919293"/></a> <a href="https://github.com/Harrington-bot"><img src="https://avatars.githubusercontent.com/u/261410808?v=4&s=48" width="48" height="48" alt="Harrington-bot" title="Harrington-bot"/></a> <a href="https://github.com/nonggialiang"><img src="https://avatars.githubusercontent.com/u/14367839?v=4&s=48" width="48" height="48" alt="nonggia.liang" title="nonggia.liang"/></a> <a href="https://github.com/TinyTb"><img src="https://avatars.githubusercontent.com/u/5957298?v=4&s=48" width="48" height="48" alt="Michael Lee" title="Michael Lee"/></a>
|
||||
<a href="https://github.com/OscarMinjarez"><img src="https://avatars.githubusercontent.com/u/86080038?v=4&s=48" width="48" height="48" alt="OscarMinjarez" title="OscarMinjarez"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/Alg0rix"><img src="https://avatars.githubusercontent.com/u/53804949?v=4&s=48" width="48" height="48" alt="Alg0rix" title="Alg0rix"/></a> <a href="https://github.com/L-U-C-K-Y"><img src="https://avatars.githubusercontent.com/u/14868134?v=4&s=48" width="48" height="48" alt="Lucky" title="Lucky"/></a> <a href="https://github.com/Kepler2024"><img src="https://avatars.githubusercontent.com/u/166882517?v=4&s=48" width="48" height="48" alt="Harry Cui Kepler" title="Harry Cui Kepler"/></a> <a href="https://github.com/h0tp-ftw"><img src="https://avatars.githubusercontent.com/u/141889580?v=4&s=48" width="48" height="48" alt="h0tp-ftw" title="h0tp-ftw"/></a> <a href="https://github.com/Youyou972"><img src="https://avatars.githubusercontent.com/u/50808411?v=4&s=48" width="48" height="48" alt="Youyou972" title="Youyou972"/></a> <a href="https://github.com/dominicnunez"><img src="https://avatars.githubusercontent.com/u/43616264?v=4&s=48" width="48" height="48" alt="Dominic" title="Dominic"/></a> <a href="https://github.com/danielwanwx"><img src="https://avatars.githubusercontent.com/u/144515713?v=4&s=48" width="48" height="48" alt="danielwanwx" title="danielwanwx"/></a> <a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a>
|
||||
<a href="https://github.com/akyourowngames"><img src="https://avatars.githubusercontent.com/u/123736861?v=4&s=48" width="48" height="48" alt="akyourowngames" title="akyourowngames"/></a> <a href="https://github.com/apps/clawdinator"><img src="https://avatars.githubusercontent.com/in/2607181?v=4&s=48" width="48" height="48" alt="clawdinator[bot]" title="clawdinator[bot]"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/thesomewhatyou"><img src="https://avatars.githubusercontent.com/u/162917831?v=4&s=48" width="48" height="48" alt="thesomewhatyou" title="thesomewhatyou"/></a> <a href="https://github.com/dashed"><img src="https://avatars.githubusercontent.com/u/139499?v=4&s=48" width="48" height="48" alt="dashed" title="dashed"/></a> <a href="https://github.com/minupla"><img src="https://avatars.githubusercontent.com/u/42547246?v=4&s=48" width="48" height="48" alt="Dale Babiy" title="Dale Babiy"/></a> <a href="https://github.com/Diaspar4u"><img src="https://avatars.githubusercontent.com/u/3605840?v=4&s=48" width="48" height="48" alt="Diaspar4u" title="Diaspar4u"/></a> <a href="https://github.com/brianleach"><img src="https://avatars.githubusercontent.com/u/1900805?v=4&s=48" width="48" height="48" alt="brianleach" title="brianleach"/></a> <a href="https://github.com/codexGW"><img src="https://avatars.githubusercontent.com/u/9350182?v=4&s=48" width="48" height="48" alt="codexGW" title="codexGW"/></a>
|
||||
<a href="https://github.com/dirbalak"><img src="https://avatars.githubusercontent.com/u/30323349?v=4&s=48" width="48" height="48" alt="dirbalak" title="dirbalak"/></a> <a href="https://github.com/Iranb"><img src="https://avatars.githubusercontent.com/u/49674669?v=4&s=48" width="48" height="48" alt="Iranb" title="Iranb"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="Max" title="Max"/></a> <a href="https://github.com/papago2355"><img src="https://avatars.githubusercontent.com/u/68721273?v=4&s=48" width="48" height="48" alt="TideFinder" title="TideFinder"/></a> <a href="https://github.com/cdorsey"><img src="https://avatars.githubusercontent.com/u/12650570?v=4&s=48" width="48" height="48" alt="Chase Dorsey" title="Chase Dorsey"/></a> <a href="https://github.com/Joly0"><img src="https://avatars.githubusercontent.com/u/13993216?v=4&s=48" width="48" height="48" alt="Joly0" title="Joly0"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/tumf"><img src="https://avatars.githubusercontent.com/u/69994?v=4&s=48" width="48" height="48" alt="tumf" title="tumf"/></a> <a href="https://github.com/slonce70"><img src="https://avatars.githubusercontent.com/u/130596182?v=4&s=48" width="48" height="48" alt="slonce70" title="slonce70"/></a> <a href="https://github.com/alexgleason"><img src="https://avatars.githubusercontent.com/u/3639540?v=4&s=48" width="48" height="48" alt="alexgleason" title="alexgleason"/></a>
|
||||
<a href="https://github.com/theonejvo"><img src="https://avatars.githubusercontent.com/u/125909656?v=4&s=48" width="48" height="48" alt="theonejvo" title="theonejvo"/></a> <a href="https://github.com/adao-max"><img src="https://avatars.githubusercontent.com/u/153898832?v=4&s=48" width="48" height="48" alt="Skyler Miao" title="Skyler Miao"/></a> <a href="https://github.com/jlowin"><img src="https://avatars.githubusercontent.com/u/153965?v=4&s=48" width="48" height="48" alt="Jeremiah Lowin" title="Jeremiah Lowin"/></a> <a href="https://github.com/peetzweg"><img src="https://avatars.githubusercontent.com/u/839848?v=4&s=48" width="48" height="48" alt="peetzweg/" title="peetzweg/"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/ghsmc"><img src="https://avatars.githubusercontent.com/u/68118719?v=4&s=48" width="48" height="48" alt="ghsmc" title="ghsmc"/></a> <a href="https://github.com/ibrahimq21"><img src="https://avatars.githubusercontent.com/u/8392472?v=4&s=48" width="48" height="48" alt="ibrahimq21" title="ibrahimq21"/></a> <a href="https://github.com/irtiq7"><img src="https://avatars.githubusercontent.com/u/3823029?v=4&s=48" width="48" height="48" alt="irtiq7" title="irtiq7"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/kelvinCB"><img src="https://avatars.githubusercontent.com/u/50544379?v=4&s=48" width="48" height="48" alt="kelvinCB" title="kelvinCB"/></a>
|
||||
<a href="https://github.com/mitsuhiko"><img src="https://avatars.githubusercontent.com/u/7396?v=4&s=48" width="48" height="48" alt="mitsuhiko" title="mitsuhiko"/></a> <a href="https://github.com/rybnikov"><img src="https://avatars.githubusercontent.com/u/7761808?v=4&s=48" width="48" height="48" alt="rybnikov" title="rybnikov"/></a> <a href="https://github.com/santiagomed"><img src="https://avatars.githubusercontent.com/u/30184543?v=4&s=48" width="48" height="48" alt="santiagomed" title="santiagomed"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/kaizen403"><img src="https://avatars.githubusercontent.com/u/134706404?v=4&s=48" width="48" height="48" alt="kaizen403" title="kaizen403"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/nk1tz"><img src="https://avatars.githubusercontent.com/u/12980165?v=4&s=48" width="48" height="48" alt="Nate" title="Nate"/></a> <a href="https://github.com/CornBrother0x"><img src="https://avatars.githubusercontent.com/u/101160087?v=4&s=48" width="48" height="48" alt="CornBrother0x" title="CornBrother0x"/></a> <a href="https://github.com/DukeDeSouth"><img src="https://avatars.githubusercontent.com/u/51200688?v=4&s=48" width="48" height="48" alt="DukeDeSouth" title="DukeDeSouth"/></a>
|
||||
<a href="https://github.com/crimeacs"><img src="https://avatars.githubusercontent.com/u/35071559?v=4&s=48" width="48" height="48" alt="crimeacs" title="crimeacs"/></a> <a href="https://github.com/liebertar"><img src="https://avatars.githubusercontent.com/u/99405438?v=4&s=48" width="48" height="48" alt="Cklee" title="Cklee"/></a> <a href="https://github.com/garnetlyx"><img src="https://avatars.githubusercontent.com/u/12513503?v=4&s=48" width="48" height="48" alt="Garnet Liu" title="Garnet Liu"/></a> <a href="https://github.com/Bermudarat"><img src="https://avatars.githubusercontent.com/u/10937319?v=4&s=48" width="48" height="48" alt="neverland" title="neverland"/></a> <a href="https://github.com/ryancontent"><img src="https://avatars.githubusercontent.com/u/39743613?v=4&s=48" width="48" height="48" alt="ryan" title="ryan"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/AdeboyeDN"><img src="https://avatars.githubusercontent.com/u/65312338?v=4&s=48" width="48" height="48" alt="AdeboyeDN" title="AdeboyeDN"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="Neo" title="Neo"/></a> <a href="https://github.com/asklee-klawd"><img src="https://avatars.githubusercontent.com/u/105007315?v=4&s=48" width="48" height="48" alt="asklee-klawd" title="asklee-klawd"/></a> <a href="https://github.com/benediktjohannes"><img src="https://avatars.githubusercontent.com/u/253604130?v=4&s=48" width="48" height="48" alt="benediktjohannes" title="benediktjohannes"/></a>
|
||||
<a href="https://github.com/zhangzhefang-github"><img src="https://avatars.githubusercontent.com/u/34058239?v=4&s=48" width="48" height="48" alt="张哲芳" title="张哲芳"/></a> <a href="https://github.com/constansino"><img src="https://avatars.githubusercontent.com/u/65108260?v=4&s=48" width="48" height="48" alt="constansino" title="constansino"/></a> <a href="https://github.com/yuting0624"><img src="https://avatars.githubusercontent.com/u/32728916?v=4&s=48" width="48" height="48" alt="Yuting Lin" title="Yuting Lin"/></a> <a href="https://github.com/joelnishanth"><img src="https://avatars.githubusercontent.com/u/140015627?v=4&s=48" width="48" height="48" alt="OfflynAI" title="OfflynAI"/></a> <a href="https://github.com/18-RAJAT"><img src="https://avatars.githubusercontent.com/u/78920780?v=4&s=48" width="48" height="48" alt="Rajat Joshi" title="Rajat Joshi"/></a> <a href="https://github.com/pahdo"><img src="https://avatars.githubusercontent.com/u/12799392?v=4&s=48" width="48" height="48" alt="Daniel Zou" title="Daniel Zou"/></a> <a href="https://github.com/manikv12"><img src="https://avatars.githubusercontent.com/u/49544491?v=4&s=48" width="48" height="48" alt="Manik Vahsith" title="Manik Vahsith"/></a> <a href="https://github.com/ProspectOre"><img src="https://avatars.githubusercontent.com/u/54486432?v=4&s=48" width="48" height="48" alt="ProspectOre" title="ProspectOre"/></a> <a href="https://github.com/detecti1"><img src="https://avatars.githubusercontent.com/u/1622461?v=4&s=48" width="48" height="48" alt="Lilo" title="Lilo"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
|
||||
<a href="https://github.com/awkoy"><img src="https://avatars.githubusercontent.com/u/13995636?v=4&s=48" width="48" height="48" alt="awkoy" title="awkoy"/></a> <a href="https://github.com/dawondyifraw"><img src="https://avatars.githubusercontent.com/u/9797257?v=4&s=48" width="48" height="48" alt="dawondyifraw" title="dawondyifraw"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/hyojin"><img src="https://avatars.githubusercontent.com/u/3413183?v=4&s=48" width="48" height="48" alt="hyojin" title="hyojin"/></a> <a href="https://github.com/Kansodata"><img src="https://avatars.githubusercontent.com/u/225288021?v=4&s=48" width="48" height="48" alt="Kansodata" title="Kansodata"/></a> <a href="https://github.com/natedenh"><img src="https://avatars.githubusercontent.com/u/13399956?v=4&s=48" width="48" height="48" alt="natedenh" title="natedenh"/></a> <a href="https://github.com/pi0"><img src="https://avatars.githubusercontent.com/u/5158436?v=4&s=48" width="48" height="48" alt="pi0" title="pi0"/></a> <a href="https://github.com/dddabtc"><img src="https://avatars.githubusercontent.com/u/104875499?v=4&s=48" width="48" height="48" alt="dddabtc" title="dddabtc"/></a> <a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4&s=48" width="48" height="48" alt="AkashKobal" title="AkashKobal"/></a> <a href="https://github.com/wu-tian807"><img src="https://avatars.githubusercontent.com/u/61640083?v=4&s=48" width="48" height="48" alt="wu-tian807" title="wu-tian807"/></a>
|
||||
<a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="Ganghyun Kim" title="Ganghyun Kim"/></a> <a href="https://github.com/sbking"><img src="https://avatars.githubusercontent.com/u/3913213?v=4&s=48" width="48" height="48" alt="Stephen Brian King" title="Stephen Brian King"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John Rood" title="John Rood"/></a> <a href="https://github.com/divisonofficer"><img src="https://avatars.githubusercontent.com/u/41609506?v=4&s=48" width="48" height="48" alt="JINNYEONG KIM" title="JINNYEONG KIM"/></a> <a href="https://github.com/dinakars777"><img src="https://avatars.githubusercontent.com/u/250428393?v=4&s=48" width="48" height="48" alt="Dinakar Sarbada" title="Dinakar Sarbada"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/Protocol-zero-0"><img src="https://avatars.githubusercontent.com/u/257158451?v=4&s=48" width="48" height="48" alt="Protocol Zero" title="Protocol Zero"/></a> <a href="https://github.com/Limitless2023"><img src="https://avatars.githubusercontent.com/u/127183162?v=4&s=48" width="48" height="48" alt="Limitless" title="Limitless"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="Mykyta Bozhenko" title="Mykyta Bozhenko"/></a>
|
||||
<a href="https://github.com/nicholascyh"><img src="https://avatars.githubusercontent.com/u/188132635?v=4&s=48" width="48" height="48" alt="Nicholas" title="Nicholas"/></a> <a href="https://github.com/shivamraut101"><img src="https://avatars.githubusercontent.com/u/110457469?v=4&s=48" width="48" height="48" alt="Shivam Kumar Raut" title="Shivam Kumar Raut"/></a> <a href="https://github.com/andreesg"><img src="https://avatars.githubusercontent.com/u/810322?v=4&s=48" width="48" height="48" alt="andreesg" title="andreesg"/></a> <a href="https://github.com/fwhite13"><img src="https://avatars.githubusercontent.com/u/173006051?v=4&s=48" width="48" height="48" alt="Fred White" title="Fred White"/></a> <a href="https://github.com/Anandesh-Sharma"><img src="https://avatars.githubusercontent.com/u/30695364?v=4&s=48" width="48" height="48" alt="Anandesh-Sharma" title="Anandesh-Sharma"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/ezhikkk"><img src="https://avatars.githubusercontent.com/u/105670095?v=4&s=48" width="48" height="48" alt="ezhikkk" title="ezhikkk"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/BinaryMuse"><img src="https://avatars.githubusercontent.com/u/189606?v=4&s=48" width="48" height="48" alt="BinaryMuse" title="BinaryMuse"/></a> <a href="https://github.com/cordx56"><img src="https://avatars.githubusercontent.com/u/23298744?v=4&s=48" width="48" height="48" alt="cordx56" title="cordx56"/></a>
|
||||
<a href="https://github.com/DevSecTim"><img src="https://avatars.githubusercontent.com/u/2226767?v=4&s=48" width="48" height="48" alt="DevSecTim" title="DevSecTim"/></a> <a href="https://github.com/edincampara"><img src="https://avatars.githubusercontent.com/u/142477787?v=4&s=48" width="48" height="48" alt="edincampara" title="edincampara"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/gildo"><img src="https://avatars.githubusercontent.com/u/133645?v=4&s=48" width="48" height="48" alt="gildo" title="gildo"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/loeclos"><img src="https://avatars.githubusercontent.com/u/116607327?v=4&s=48" width="48" height="48" alt="loeclos" title="loeclos"/></a> <a href="https://github.com/MarvinCui"><img src="https://avatars.githubusercontent.com/u/130876763?v=4&s=48" width="48" height="48" alt="MarvinCui" title="MarvinCui"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/thejhinvirtuoso"><img src="https://avatars.githubusercontent.com/u/258521837?v=4&s=48" width="48" height="48" alt="thejhinvirtuoso" title="thejhinvirtuoso"/></a>
|
||||
<a href="https://github.com/yudshj"><img src="https://avatars.githubusercontent.com/u/16971372?v=4&s=48" width="48" height="48" alt="yudshj" title="yudshj"/></a> <a href="https://github.com/Wangnov"><img src="https://avatars.githubusercontent.com/u/48670012?v=4&s=48" width="48" height="48" alt="Wangnov" title="Wangnov"/></a> <a href="https://github.com/JonathanWorks"><img src="https://avatars.githubusercontent.com/u/124476234?v=4&s=48" width="48" height="48" alt="Jonathan Works" title="Jonathan Works"/></a> <a href="https://github.com/yassine20011"><img src="https://avatars.githubusercontent.com/u/59234686?v=4&s=48" width="48" height="48" alt="Yassine Amjad" title="Yassine Amjad"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/hirefrank"><img src="https://avatars.githubusercontent.com/u/183158?v=4&s=48" width="48" height="48" alt="Frank Harris" title="Frank Harris"/></a> <a href="https://github.com/kennyklee"><img src="https://avatars.githubusercontent.com/u/1432489?v=4&s=48" width="48" height="48" alt="Kenny Lee" title="Kenny Lee"/></a> <a href="https://github.com/ThomsenDrake"><img src="https://avatars.githubusercontent.com/u/120344051?v=4&s=48" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/wangai-studio"><img src="https://avatars.githubusercontent.com/u/256938352?v=4&s=48" width="48" height="48" alt="wangai-studio" title="wangai-studio"/></a> <a href="https://github.com/AytuncYildizli"><img src="https://avatars.githubusercontent.com/u/47717026?v=4&s=48" width="48" height="48" alt="AytuncYildizli" title="AytuncYildizli"/></a>
|
||||
<a href="https://github.com/KnHack"><img src="https://avatars.githubusercontent.com/u/2346724?v=4&s=48" width="48" height="48" alt="Charlie Niño" title="Charlie Niño"/></a> <a href="https://github.com/17jmumford"><img src="https://avatars.githubusercontent.com/u/36290330?v=4&s=48" width="48" height="48" alt="Jeremy Mumford" title="Jeremy Mumford"/></a> <a href="https://github.com/Yeom-JinHo"><img src="https://avatars.githubusercontent.com/u/81306489?v=4&s=48" width="48" height="48" alt="Yeom-JinHo" title="Yeom-JinHo"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="Rob Axelsen" title="Rob Axelsen"/></a> <a href="https://github.com/junjunjunbong"><img src="https://avatars.githubusercontent.com/u/153147718?v=4&s=48" width="48" height="48" alt="junwon" title="junwon"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="Pratham Dubey" title="Pratham Dubey"/></a> <a href="https://github.com/amitbiswal007"><img src="https://avatars.githubusercontent.com/u/108086198?v=4&s=48" width="48" height="48" alt="amitbiswal007" title="amitbiswal007"/></a> <a href="https://github.com/Slats24"><img src="https://avatars.githubusercontent.com/u/42514321?v=4&s=48" width="48" height="48" alt="Slats" title="Slats"/></a> <a href="https://github.com/orenyomtov"><img src="https://avatars.githubusercontent.com/u/168856?v=4&s=48" width="48" height="48" alt="Oren" title="Oren"/></a> <a href="https://github.com/parkertoddbrooks"><img src="https://avatars.githubusercontent.com/u/585456?v=4&s=48" width="48" height="48" alt="Parker Todd Brooks" title="Parker Todd Brooks"/></a>
|
||||
<a href="https://github.com/mattqdev"><img src="https://avatars.githubusercontent.com/u/115874885?v=4&s=48" width="48" height="48" alt="MattQ" title="MattQ"/></a> <a href="https://github.com/Milofax"><img src="https://avatars.githubusercontent.com/u/2537423?v=4&s=48" width="48" height="48" alt="Milofax" title="Milofax"/></a> <a href="https://github.com/stevebot-alive"><img src="https://avatars.githubusercontent.com/u/261149299?v=4&s=48" width="48" height="48" alt="Steve (OpenClaw)" title="Steve (OpenClaw)"/></a> <a href="https://github.com/ZetiMente"><img src="https://avatars.githubusercontent.com/u/76985631?v=4&s=48" width="48" height="48" alt="Matthew" title="Matthew"/></a> <a href="https://github.com/Cassius0924"><img src="https://avatars.githubusercontent.com/u/62874592?v=4&s=48" width="48" height="48" alt="Cassius0924" title="Cassius0924"/></a> <a href="https://github.com/0xbrak"><img src="https://avatars.githubusercontent.com/u/181251288?v=4&s=48" width="48" height="48" alt="0xbrak" title="0xbrak"/></a> <a href="https://github.com/8BlT"><img src="https://avatars.githubusercontent.com/u/162764392?v=4&s=48" width="48" height="48" alt="8BlT" title="8BlT"/></a> <a href="https://github.com/Abdul535"><img src="https://avatars.githubusercontent.com/u/54276938?v=4&s=48" width="48" height="48" alt="Abdul535" title="Abdul535"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/aduk059"><img src="https://avatars.githubusercontent.com/u/257603478?v=4&s=48" width="48" height="48" alt="aduk059" title="aduk059"/></a>
|
||||
<a href="https://github.com/afurm"><img src="https://avatars.githubusercontent.com/u/6375192?v=4&s=48" width="48" height="48" alt="afurm" title="afurm"/></a> <a href="https://github.com/aisling404"><img src="https://avatars.githubusercontent.com/u/211950534?v=4&s=48" width="48" height="48" alt="aisling404" title="aisling404"/></a> <a href="https://github.com/akari-musubi"><img src="https://avatars.githubusercontent.com/u/259925157?v=4&s=48" width="48" height="48" alt="akari-musubi" title="akari-musubi"/></a> <a href="https://github.com/albertlieyingadrian"><img src="https://avatars.githubusercontent.com/u/12984659?v=4&s=48" width="48" height="48" alt="albertlieyingadrian" title="albertlieyingadrian"/></a> <a href="https://github.com/Alex-Alaniz"><img src="https://avatars.githubusercontent.com/u/88956822?v=4&s=48" width="48" height="48" alt="Alex-Alaniz" title="Alex-Alaniz"/></a> <a href="https://github.com/ali-aljufairi"><img src="https://avatars.githubusercontent.com/u/85583841?v=4&s=48" width="48" height="48" alt="ali-aljufairi" title="ali-aljufairi"/></a> <a href="https://github.com/altaywtf"><img src="https://avatars.githubusercontent.com/u/9790196?v=4&s=48" width="48" height="48" alt="altaywtf" title="altaywtf"/></a> <a href="https://github.com/araa47"><img src="https://avatars.githubusercontent.com/u/22760261?v=4&s=48" width="48" height="48" alt="araa47" title="araa47"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/avacadobanana352"><img src="https://avatars.githubusercontent.com/u/263496834?v=4&s=48" width="48" height="48" alt="avacadobanana352" title="avacadobanana352"/></a>
|
||||
<a href="https://github.com/barronlroth"><img src="https://avatars.githubusercontent.com/u/5567884?v=4&s=48" width="48" height="48" alt="barronlroth" title="barronlroth"/></a> <a href="https://github.com/bennewton999"><img src="https://avatars.githubusercontent.com/u/458991?v=4&s=48" width="48" height="48" alt="bennewton999" title="bennewton999"/></a> <a href="https://github.com/bguidolim"><img src="https://avatars.githubusercontent.com/u/987360?v=4&s=48" width="48" height="48" alt="bguidolim" title="bguidolim"/></a> <a href="https://github.com/bigwest60"><img src="https://avatars.githubusercontent.com/u/12373979?v=4&s=48" width="48" height="48" alt="bigwest60" title="bigwest60"/></a> <a href="https://github.com/caelum0x"><img src="https://avatars.githubusercontent.com/u/130079063?v=4&s=48" width="48" height="48" alt="caelum0x" title="caelum0x"/></a> <a href="https://github.com/championswimmer"><img src="https://avatars.githubusercontent.com/u/1327050?v=4&s=48" width="48" height="48" alt="championswimmer" title="championswimmer"/></a> <a href="https://github.com/dutifulbob"><img src="https://avatars.githubusercontent.com/u/261991368?v=4&s=48" width="48" height="48" alt="dutifulbob" title="dutifulbob"/></a> <a href="https://github.com/eternauta1337"><img src="https://avatars.githubusercontent.com/u/550409?v=4&s=48" width="48" height="48" alt="eternauta1337" title="eternauta1337"/></a> <a href="https://github.com/foeken"><img src="https://avatars.githubusercontent.com/u/13864?v=4&s=48" width="48" height="48" alt="foeken" title="foeken"/></a> <a href="https://github.com/gittb"><img src="https://avatars.githubusercontent.com/u/8284364?v=4&s=48" width="48" height="48" alt="gittb" title="gittb"/></a>
|
||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/junsuwhy"><img src="https://avatars.githubusercontent.com/u/4645498?v=4&s=48" width="48" height="48" alt="junsuwhy" title="junsuwhy"/></a> <a href="https://github.com/knocte"><img src="https://avatars.githubusercontent.com/u/331303?v=4&s=48" width="48" height="48" alt="knocte" title="knocte"/></a> <a href="https://github.com/MackDing"><img src="https://avatars.githubusercontent.com/u/19878893?v=4&s=48" width="48" height="48" alt="MackDing" title="MackDing"/></a> <a href="https://github.com/nobrainer-tech"><img src="https://avatars.githubusercontent.com/u/445466?v=4&s=48" width="48" height="48" alt="nobrainer-tech" title="nobrainer-tech"/></a> <a href="https://github.com/Noctivoro"><img src="https://avatars.githubusercontent.com/u/183974570?v=4&s=48" width="48" height="48" alt="Noctivoro" title="Noctivoro"/></a> <a href="https://github.com/Raikan10"><img src="https://avatars.githubusercontent.com/u/20675476?v=4&s=48" width="48" height="48" alt="Raikan10" title="Raikan10"/></a> <a href="https://github.com/Swader"><img src="https://avatars.githubusercontent.com/u/1430603?v=4&s=48" width="48" height="48" alt="Swader" title="Swader"/></a> <a href="https://github.com/algal"><img src="https://avatars.githubusercontent.com/u/264412?v=4&s=48" width="48" height="48" alt="Alexis Gallagher" title="Alexis Gallagher"/></a> <a href="https://github.com/alexstyl"><img src="https://avatars.githubusercontent.com/u/1665273?v=4&s=48" width="48" height="48" alt="alexstyl" title="alexstyl"/></a> <a href="https://github.com/ethanpalm"><img src="https://avatars.githubusercontent.com/u/56270045?v=4&s=48" width="48" height="48" alt="Ethan Palm" title="Ethan Palm"/></a>
|
||||
<a href="https://github.com/yingchunbai"><img src="https://avatars.githubusercontent.com/u/33477283?v=4&s=48" width="48" height="48" alt="yingchunbai" title="yingchunbai"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/danballance"><img src="https://avatars.githubusercontent.com/u/13839912?v=4&s=48" width="48" height="48" alt="Dan Ballance" title="Dan Ballance"/></a> <a href="https://github.com/GHesericsu"><img src="https://avatars.githubusercontent.com/u/60202455?v=4&s=48" width="48" height="48" alt="Eric Su" title="Eric Su"/></a> <a href="https://github.com/kimitaka"><img src="https://avatars.githubusercontent.com/u/167225?v=4&s=48" width="48" height="48" alt="Kimitaka Watanabe" title="Kimitaka Watanabe"/></a> <a href="https://github.com/itsjling"><img src="https://avatars.githubusercontent.com/u/2521993?v=4&s=48" width="48" height="48" alt="Justin Ling" title="Justin Ling"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/RayBB"><img src="https://avatars.githubusercontent.com/u/921217?v=4&s=48" width="48" height="48" alt="Raymond Berger" title="Raymond Berger"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a>
|
||||
<a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/efe-buken"><img src="https://avatars.githubusercontent.com/u/262546946?v=4&s=48" width="48" height="48" alt="efe-buken" title="efe-buken"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/easternbloc"><img src="https://avatars.githubusercontent.com/u/92585?v=4&s=48" width="48" height="48" alt="easternbloc" title="easternbloc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||
<a href="https://github.com/sktbrd"><img src="https://avatars.githubusercontent.com/u/116202536?v=4&s=48" width="48" height="48" alt="sktbrd" title="sktbrd"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/Mind-Dragon"><img src="https://avatars.githubusercontent.com/u/262945885?v=4&s=48" width="48" height="48" alt="Mind-Dragon" title="Mind-Dragon"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/tmchow"><img src="https://avatars.githubusercontent.com/u/517103?v=4&s=48" width="48" height="48" alt="tmchow" title="tmchow"/></a> <a href="https://github.com/uli-will-code"><img src="https://avatars.githubusercontent.com/u/49715419?v=4&s=48" width="48" height="48" alt="uli-will-code" title="uli-will-code"/></a> <a href="https://github.com/mgratch"><img src="https://avatars.githubusercontent.com/u/2238658?v=4&s=48" width="48" height="48" alt="Marc Gratch" title="Marc Gratch"/></a> <a href="https://github.com/JackyWay"><img src="https://avatars.githubusercontent.com/u/53031570?v=4&s=48" width="48" height="48" alt="JackyWay" title="JackyWay"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/CJWTRUST"><img src="https://avatars.githubusercontent.com/u/235565898?v=4&s=48" width="48" height="48" alt="CJWTRUST" title="CJWTRUST"/></a>
|
||||
<a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/mujiannan"><img src="https://avatars.githubusercontent.com/u/46643837?v=4&s=48" width="48" height="48" alt="mujiannan" title="mujiannan"/></a> <a href="https://github.com/marcodd23"><img src="https://avatars.githubusercontent.com/u/3519682?v=4&s=48" width="48" height="48" alt="Marco Di Dionisio" title="Marco Di Dionisio"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/afern247"><img src="https://avatars.githubusercontent.com/u/34192856?v=4&s=48" width="48" height="48" alt="afern247" title="afern247"/></a> <a href="https://github.com/0oAstro"><img src="https://avatars.githubusercontent.com/u/79555780?v=4&s=48" width="48" height="48" alt="0oAstro" title="0oAstro"/></a> <a href="https://github.com/alexanderatallah"><img src="https://avatars.githubusercontent.com/u/1011391?v=4&s=48" width="48" height="48" alt="alexanderatallah" title="alexanderatallah"/></a>
|
||||
<a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a>
|
||||
<a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
</p>
|
||||
|
||||
@@ -12,7 +12,6 @@ import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.Executors
|
||||
@@ -133,38 +132,38 @@ class GatewayDiscovery(
|
||||
object : NsdManager.ResolveListener {
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {}
|
||||
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
val host = resolved.host?.hostAddress ?: return
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
override fun onServiceResolved(resolved: NsdServiceInfo) {
|
||||
val host = resolved.host?.hostAddress ?: return
|
||||
val port = resolved.port
|
||||
if (port <= 0) return
|
||||
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val lanHost = txt(resolved, "lanHost")
|
||||
val tailnetDns = txt(resolved, "tailnetDns")
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
||||
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
val rawServiceName = resolved.serviceName
|
||||
val serviceName = BonjourEscapes.decode(rawServiceName)
|
||||
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
|
||||
val lanHost = txt(resolved, "lanHost")
|
||||
val tailnetDns = txt(resolved, "tailnetDns")
|
||||
val gatewayPort = txtInt(resolved, "gatewayPort")
|
||||
val canvasPort = txtInt(resolved, "canvasPort")
|
||||
val tlsEnabled = txtBool(resolved, "gatewayTls")
|
||||
val tlsFingerprint = txt(resolved, "gatewayTlsSha256")
|
||||
val id = stableId(serviceName, "local.")
|
||||
localById[id] =
|
||||
GatewayEndpoint(
|
||||
stableId = id,
|
||||
name = displayName,
|
||||
host = host,
|
||||
port = port,
|
||||
lanHost = lanHost,
|
||||
tailnetDns = tailnetDns,
|
||||
gatewayPort = gatewayPort,
|
||||
canvasPort = canvasPort,
|
||||
tlsEnabled = tlsEnabled,
|
||||
tlsFingerprintSha256 = tlsFingerprint,
|
||||
)
|
||||
publish()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
@@ -351,7 +350,7 @@ class GatewayDiscovery(
|
||||
}
|
||||
|
||||
private fun records(msg: Message?, section: Int): List<Record> {
|
||||
return msg?.getSection(section).orEmpty()
|
||||
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||
}
|
||||
|
||||
private fun keyName(raw: String): String {
|
||||
@@ -427,14 +426,14 @@ class GatewayDiscovery(
|
||||
try {
|
||||
SimpleResolver().apply {
|
||||
setAddress(InetSocketAddress(addr, 53))
|
||||
setTimeout(Duration.ofSeconds(3))
|
||||
setTimeout(3)
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (resolvers.isEmpty()) return null
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(Duration.ofSeconds(3)) }
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
|
||||
settings.builtInZoomControls = false
|
||||
settings.displayZoomControls = false
|
||||
settings.setSupportZoom(false)
|
||||
// targetSdk 33+ ignores Force Dark APIs, so only opt out through the supported
|
||||
// algorithmic darkening flag when this WebView implementation exposes it.
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
|
||||
WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, false)
|
||||
} else {
|
||||
disableForceDarkIfSupported(settings)
|
||||
}
|
||||
if (isDebuggable) {
|
||||
Log.d("OpenClawWebView", "userAgent: ${settings.userAgentString}")
|
||||
@@ -157,6 +157,12 @@ fun CanvasScreen(viewModel: MainViewModel, visible: Boolean, modifier: Modifier
|
||||
)
|
||||
}
|
||||
|
||||
private fun disableForceDarkIfSupported(settings: WebSettings) {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) return
|
||||
@Suppress("DEPRECATION")
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
|
||||
internal class CanvasA2UIActionBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val onMessage: (String) -> Unit,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
4fec95c9ce02dddb4d3021812cf68df8b4cc92c5ba4db35778bb1bfe6fa63021 config-baseline.json
|
||||
aafbb407e62908709e90f750ea0f8274016fcfcbd613394896ff984f967f236e config-baseline.core.json
|
||||
900c26a9b060f1dfa712abfba877bd3bf9c7b0c9f2294faf9834038283ec24b6 config-baseline.json
|
||||
d956a1d60f776bba712cb04374a4f5657cad95bb088b536c5e3e4e29d4a21328 config-baseline.core.json
|
||||
ef83a06633fc001b5b2535566939186ecb49d05cd1a90b40e54cc58d3e6e44e3 config-baseline.channel.json
|
||||
5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
c2c6319c35f152d2a2b36584981b92c22f7e9759a27d47ad66bfdbcef916eace plugin-sdk-api-baseline.json
|
||||
3ba23b54667c75caba3560cc66a399b7bdd9b316009bf5ad6a43aefd469f1552 plugin-sdk-api-baseline.jsonl
|
||||
73091009a0a45c72eded8003fdf9cf4c10e9470c4a055592a98ea00d55cd45d1 plugin-sdk-api-baseline.json
|
||||
9c9d59ffc0b3b6677794cb8fd5afd0208dbc9f3cd1ad59b30ee627f6f6352929 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -207,10 +207,6 @@
|
||||
"source": "Release Policy",
|
||||
"target": "发布策略"
|
||||
},
|
||||
{
|
||||
"source": "Testing CI Policy",
|
||||
"target": "测试 CI 策略"
|
||||
},
|
||||
{
|
||||
"source": "Release policy",
|
||||
"target": "发布策略"
|
||||
|
||||
@@ -496,11 +496,7 @@
|
||||
},
|
||||
{
|
||||
"group": "发布策略",
|
||||
"pages": [
|
||||
"zh-CN/reference/RELEASING",
|
||||
"zh-CN/reference/testing-ci-policy",
|
||||
"zh-CN/reference/test"
|
||||
]
|
||||
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -116,91 +116,6 @@ What this means:
|
||||
- `config.promptStyle: "balanced"` uses the default general-purpose prompt style for `recent` mode
|
||||
- active memory still runs only on eligible interactive persistent chat sessions
|
||||
|
||||
## Speed recommendations
|
||||
|
||||
The simplest setup is to leave `config.model` unset and let Active Memory use
|
||||
the same model you already use for normal replies. That is the safest default
|
||||
because it follows your existing provider, auth, and model preferences.
|
||||
|
||||
If you want Active Memory to feel faster, use a dedicated inference model
|
||||
instead of borrowing the main chat model.
|
||||
|
||||
Example fast-provider setup:
|
||||
|
||||
```json5
|
||||
models: {
|
||||
providers: {
|
||||
cerebras: {
|
||||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
apiKey: "${CEREBRAS_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "gpt-oss-120b", name: "GPT OSS 120B (Cerebras)" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
model: "cerebras/gpt-oss-120b",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Fast-model options worth considering:
|
||||
|
||||
- `cerebras/gpt-oss-120b` for a fast dedicated recall model with a narrow tool surface
|
||||
- your normal session model, by leaving `config.model` unset
|
||||
- a low-latency fallback model such as `google/gemini-3-flash` when you want a separate recall model without changing your primary chat model
|
||||
|
||||
Why Cerebras is a strong speed-oriented option for Active Memory:
|
||||
|
||||
- the Active Memory tool surface is narrow: it only calls `memory_search` and `memory_get`
|
||||
- recall quality matters, but latency matters more than for the main answer path
|
||||
- a dedicated fast provider avoids tying memory recall latency to your primary chat provider
|
||||
|
||||
If you do not want a separate speed-optimized model, leave `config.model` unset
|
||||
and let Active Memory inherit the current session model.
|
||||
|
||||
### Cerebras setup
|
||||
|
||||
Add a provider entry like this:
|
||||
|
||||
```json5
|
||||
models: {
|
||||
providers: {
|
||||
cerebras: {
|
||||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
apiKey: "${CEREBRAS_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "gpt-oss-120b", name: "GPT OSS 120B (Cerebras)" }],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Then point Active Memory at it:
|
||||
|
||||
```json5
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
enabled: true,
|
||||
config: {
|
||||
model: "cerebras/gpt-oss-120b",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Caveat:
|
||||
|
||||
- make sure the Cerebras API key actually has model access for the model you choose, because `/v1/models` visibility alone does not guarantee `chat/completions` access
|
||||
|
||||
## How to see it
|
||||
|
||||
Active memory injects a hidden untrusted prompt prefix for the model. It does
|
||||
|
||||
@@ -177,19 +177,6 @@ and the effective agent skill allowlist when `agents.defaults.skills` or
|
||||
|
||||
This keeps the base prompt small while still enabling targeted skill usage.
|
||||
|
||||
The skills list budget is owned by the skills subsystem:
|
||||
|
||||
- Global default: `skills.limits.maxSkillsPromptChars`
|
||||
- Per-agent override: `agents.list[].skillsLimits.maxSkillsPromptChars`
|
||||
|
||||
Generic bounded runtime excerpts use a different surface:
|
||||
|
||||
- `agents.defaults.contextLimits.*`
|
||||
- `agents.list[].contextLimits.*`
|
||||
|
||||
That split keeps skills sizing separate from runtime read/injection sizing such
|
||||
as `memory_get`, live tool results, and post-compaction AGENTS.md refreshes.
|
||||
|
||||
## Documentation
|
||||
|
||||
When available, the system prompt includes a **Documentation** section that points to the
|
||||
|
||||
@@ -1573,7 +1573,7 @@
|
||||
},
|
||||
{
|
||||
"group": "Release policy",
|
||||
"pages": ["reference/RELEASING", "reference/testing-ci-policy", "reference/test"]
|
||||
"pages": ["reference/RELEASING", "reference/test"]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -988,142 +988,6 @@ Default: `"once"`.
|
||||
}
|
||||
```
|
||||
|
||||
### Context budget ownership map
|
||||
|
||||
OpenClaw has multiple high-volume prompt/context budgets, and they are
|
||||
intentionally split by subsystem instead of all flowing through one generic
|
||||
knob.
|
||||
|
||||
- `agents.defaults.bootstrapMaxChars` /
|
||||
`agents.defaults.bootstrapTotalMaxChars`:
|
||||
normal workspace bootstrap injection.
|
||||
- `agents.defaults.startupContext.*`:
|
||||
one-shot `/new` and `/reset` startup prelude, including recent daily
|
||||
`memory/*.md` files.
|
||||
- `skills.limits.*`:
|
||||
the compact skills list injected into the system prompt.
|
||||
- `agents.defaults.contextLimits.*`:
|
||||
bounded runtime excerpts and injected runtime-owned blocks.
|
||||
- `memory.qmd.limits.*`:
|
||||
indexed memory-search snippet and injection sizing.
|
||||
|
||||
Use the matching per-agent override only when one agent needs a different
|
||||
budget:
|
||||
|
||||
- `agents.list[].skillsLimits.maxSkillsPromptChars`
|
||||
- `agents.list[].contextLimits.*`
|
||||
|
||||
#### `agents.defaults.startupContext`
|
||||
|
||||
Controls the first-turn startup prelude injected on bare `/new` and `/reset`
|
||||
runs.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
startupContext: {
|
||||
enabled: true,
|
||||
applyOn: ["new", "reset"],
|
||||
dailyMemoryDays: 2,
|
||||
maxFileBytes: 16384,
|
||||
maxFileChars: 1200,
|
||||
maxTotalChars: 2800,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `agents.defaults.contextLimits`
|
||||
|
||||
Shared defaults for bounded runtime context surfaces.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
contextLimits: {
|
||||
memoryGetMaxChars: 12000,
|
||||
memoryGetDefaultLines: 120,
|
||||
toolResultMaxChars: 16000,
|
||||
postCompactionMaxChars: 1800,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `memoryGetMaxChars`: default `memory_get` excerpt cap before truncation
|
||||
metadata and continuation notice are added.
|
||||
- `memoryGetDefaultLines`: default `memory_get` line window when `lines` is
|
||||
omitted.
|
||||
- `toolResultMaxChars`: live tool-result cap used for persisted results and
|
||||
overflow recovery.
|
||||
- `postCompactionMaxChars`: AGENTS.md excerpt cap used during post-compaction
|
||||
refresh injection.
|
||||
|
||||
#### `agents.list[].contextLimits`
|
||||
|
||||
Per-agent override for the shared `contextLimits` knobs. Omitted fields inherit
|
||||
from `agents.defaults.contextLimits`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
contextLimits: {
|
||||
memoryGetMaxChars: 12000,
|
||||
toolResultMaxChars: 16000,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "tiny-local",
|
||||
contextLimits: {
|
||||
memoryGetMaxChars: 6000,
|
||||
toolResultMaxChars: 8000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `skills.limits.maxSkillsPromptChars`
|
||||
|
||||
Global cap for the compact skills list injected into the system prompt. This
|
||||
does not affect reading `SKILL.md` files on demand.
|
||||
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
limits: {
|
||||
maxSkillsPromptChars: 18000,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### `agents.list[].skillsLimits.maxSkillsPromptChars`
|
||||
|
||||
Per-agent override for the skills prompt budget.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "tiny-local",
|
||||
skillsLimits: {
|
||||
maxSkillsPromptChars: 6000,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.imageMaxDimensionPx`
|
||||
|
||||
Max pixel size for the longest image side in transcript/tool image blocks before provider calls.
|
||||
|
||||
@@ -73,24 +73,10 @@ Gateway → Client:
|
||||
"type": "res",
|
||||
"id": "…",
|
||||
"ok": true,
|
||||
"payload": {
|
||||
"type": "hello-ok",
|
||||
"protocol": 3,
|
||||
"server": { "version": "…", "connId": "…" },
|
||||
"features": { "methods": ["…"], "events": ["…"] },
|
||||
"snapshot": { "…": "…" },
|
||||
"policy": {
|
||||
"maxPayload": 26214400,
|
||||
"maxBufferedBytes": 52428800,
|
||||
"tickIntervalMs": 15000
|
||||
}
|
||||
}
|
||||
"payload": { "type": "hello-ok", "protocol": 3, "policy": { "tickIntervalMs": 15000 } }
|
||||
}
|
||||
```
|
||||
|
||||
`server`, `features`, `snapshot`, and `policy` are all required by the schema
|
||||
(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional.
|
||||
|
||||
When a device token is issued, `hello-ok` also includes:
|
||||
|
||||
```json
|
||||
@@ -506,36 +492,13 @@ implemented in `src/gateway/server-methods/*.ts`.
|
||||
|
||||
## Versioning
|
||||
|
||||
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema/protocol-schemas.ts`.
|
||||
- `PROTOCOL_VERSION` lives in `src/gateway/protocol/schema.ts`.
|
||||
- Clients send `minProtocol` + `maxProtocol`; the server rejects mismatches.
|
||||
- Schemas + models are generated from TypeBox definitions:
|
||||
- `pnpm protocol:gen`
|
||||
- `pnpm protocol:gen:swift`
|
||||
- `pnpm protocol:check`
|
||||
|
||||
### Client constants
|
||||
|
||||
The reference client in `src/gateway/client.ts` uses these defaults. Values are
|
||||
stable across protocol v3 and are the expected baseline for third-party clients.
|
||||
|
||||
| Constant | Default | Source |
|
||||
| ----------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| `PROTOCOL_VERSION` | `3` | `src/gateway/protocol/schema/protocol-schemas.ts` |
|
||||
| Request timeout (per RPC) | `30_000` ms | `src/gateway/client.ts` (`requestTimeoutMs`) |
|
||||
| Preauth / connect-challenge timeout | `10_000` ms | `src/gateway/handshake-timeouts.ts` (clamp `250`–`10_000`) |
|
||||
| Initial reconnect backoff | `1_000` ms | `src/gateway/client.ts` (`backoffMs`) |
|
||||
| Max reconnect backoff | `30_000` ms | `src/gateway/client.ts` (`scheduleReconnect`) |
|
||||
| Fast-retry clamp after device-token close | `250` ms | `src/gateway/client.ts` |
|
||||
| Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` |
|
||||
| `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` |
|
||||
| Default tick interval (pre `hello-ok`) | `30_000` ms | `src/gateway/client.ts` |
|
||||
| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `src/gateway/client.ts` |
|
||||
| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` |
|
||||
|
||||
The server advertises the effective `policy.tickIntervalMs`, `policy.maxPayload`,
|
||||
and `policy.maxBufferedBytes` in `hello-ok`; clients should honor those values
|
||||
rather than the pre-handshake defaults.
|
||||
|
||||
## Auth
|
||||
|
||||
- Shared-secret gateway auth uses `connect.params.auth.token` or
|
||||
@@ -555,18 +518,8 @@ rather than the pre-handshake defaults.
|
||||
approved scope set for that token. This preserves read/probe/status access
|
||||
that was already granted and avoids silently collapsing reconnects to a
|
||||
narrower implicit admin-only scope.
|
||||
- Client-side connect auth assembly (`selectConnectAuth` in
|
||||
`src/gateway/client.ts`):
|
||||
- `auth.password` is orthogonal and is always forwarded when set.
|
||||
- `auth.token` is populated in priority order: explicit shared token first,
|
||||
then an explicit `deviceToken`, then a stored per-device token (keyed by
|
||||
`deviceId` + `role`).
|
||||
- `auth.bootstrapToken` is sent only when none of the above resolved an
|
||||
`auth.token`. A shared token or any resolved device token suppresses it.
|
||||
- Auto-promotion of a stored device token on the one-shot
|
||||
`AUTH_TOKEN_MISMATCH` retry is gated to **trusted endpoints only** —
|
||||
loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://`
|
||||
without pinning does not qualify.
|
||||
- Normal connect auth precedence is explicit shared token/password first, then
|
||||
explicit `deviceToken`, then stored per-device token, then bootstrap token.
|
||||
- Additional `hello-ok.auth.deviceTokens` entries are bootstrap handoff tokens.
|
||||
Persist them only when the connect used bootstrap auth on a trusted transport
|
||||
such as `wss://` or loopback/local pairing.
|
||||
|
||||
@@ -348,8 +348,6 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- Catch provider format changes, tool-calling quirks, auth issues, and rate limit behavior
|
||||
- Expectations:
|
||||
- Not CI-stable by design (real networks, real provider policies, quotas, outages)
|
||||
- That usually means "run this in release or scheduled CI instead of PR CI"
|
||||
- Do not read this as "only run it by hand"
|
||||
- Costs money / uses rate limits
|
||||
- Prefer running narrowed subsets instead of “everything”
|
||||
- Live runs source `~/.profile` to pick up missing API keys.
|
||||
@@ -848,10 +846,6 @@ These Docker runners split into two buckets:
|
||||
explicitly want the larger exhaustive scan.
|
||||
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the two live Docker lanes.
|
||||
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
|
||||
- Use [Testing CI Policy](/reference/testing-ci-policy) to decide whether a
|
||||
runner belongs in `PR CI`, `release CI`, `scheduled CI`, or `manual only`.
|
||||
The important part is this: a suite can be required in CI even when it does
|
||||
not block every PR or live in the publish workflow.
|
||||
|
||||
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
|
||||
|
||||
@@ -889,9 +883,6 @@ This lane expects a usable live model key, and `OPENCLAW_PROFILE_FILE`
|
||||
(`~/.profile` by default) is the primary way to provide it in Dockerized runs.
|
||||
Successful runs print a small JSON payload like `{ "ok": true, "model":
|
||||
"openclaw/default", ... }`.
|
||||
Keep this in release or scheduled CI if it matters for the product surface you
|
||||
are changing. Being slower than a normal PR lane is not a reason to quietly
|
||||
drop it to manual-only.
|
||||
`test:docker:mcp-channels` is intentionally deterministic and does not need a
|
||||
real Telegram, Discord, or iMessage account. It boots a seeded Gateway
|
||||
container, starts a second container that spawns `openclaw mcp serve`, then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Google (Gemini)"
|
||||
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, TTS, web search)"
|
||||
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, web search)"
|
||||
read_when:
|
||||
- You want to use Google Gemini models with OpenClaw
|
||||
- You need the API key or OAuth auth flow
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
# Google (Gemini)
|
||||
|
||||
The Google plugin provides access to Gemini models through Google AI Studio, plus
|
||||
image generation, media understanding (image/audio/video), text-to-speech, and web search via
|
||||
image generation, media understanding (image/audio/video), and web search via
|
||||
Gemini Grounding.
|
||||
|
||||
- Provider: `google`
|
||||
@@ -133,7 +133,6 @@ Choose your preferred auth method and follow the setup steps.
|
||||
| Chat completions | Yes |
|
||||
| Image generation | Yes |
|
||||
| Music generation | Yes |
|
||||
| Text-to-speech | Yes |
|
||||
| Image understanding | Yes |
|
||||
| Audio transcription | Yes |
|
||||
| Video understanding | Yes |
|
||||
@@ -234,50 +233,6 @@ To use Google as the default music provider:
|
||||
See [Music Generation](/tools/music-generation) for shared tool parameters, provider selection, and failover behavior.
|
||||
</Note>
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
The bundled `google` speech provider uses the Gemini API TTS path with
|
||||
`gemini-3.1-flash-tts-preview`.
|
||||
|
||||
- Default voice: `Kore`
|
||||
- Auth: `messages.tts.providers.google.apiKey`, `models.providers.google.apiKey`, `GEMINI_API_KEY`, or `GOOGLE_API_KEY`
|
||||
- Output: WAV for regular TTS attachments, PCM for Talk/telephony
|
||||
- Native voice-note output: not supported on this Gemini API path because the API returns PCM rather than Opus
|
||||
|
||||
To use Google as the default TTS provider:
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "google",
|
||||
providers: {
|
||||
google: {
|
||||
model: "gemini-3.1-flash-tts-preview",
|
||||
voiceName: "Kore",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Gemini API TTS accepts expressive square-bracket audio tags in the text, such as
|
||||
`[whispers]` or `[laughs]`. To keep tags out of the visible chat reply while
|
||||
sending them to TTS, put them inside a `[[tts:text]]...[[/tts:text]]` block:
|
||||
|
||||
```text
|
||||
Here is the clean reply text.
|
||||
|
||||
[[tts:text]][whispers] Here is the spoken version.[[/tts:text]]
|
||||
```
|
||||
|
||||
<Note>
|
||||
A Google Cloud Console API key restricted to the Gemini API is valid for this
|
||||
provider. This is not the separate Cloud Text-to-Speech API path.
|
||||
</Note>
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
# Async Exec Duplicate Completion Investigation
|
||||
|
||||
## Scope
|
||||
|
||||
- Session: `agent:main:telegram:group:-1003774691294:topic:1`
|
||||
- Symptom: the same async exec completion for session/run `keen-nexus` was recorded twice in LCM as user turns.
|
||||
- Goal: identify whether this is most likely duplicate session injection or plain outbound delivery retry.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Most likely this is **duplicate session injection**, not a pure outbound delivery retry.
|
||||
|
||||
The strongest gateway-side gap is in the **node exec completion path**:
|
||||
|
||||
1. A node-side exec finish emits `exec.finished` with the full `runId`.
|
||||
2. Gateway `server-node-events` converts that into a system event and requests a heartbeat.
|
||||
3. The heartbeat run injects the drained system event block into the agent prompt.
|
||||
4. The embedded runner persists that prompt as a new user turn in the session transcript.
|
||||
|
||||
If the same `exec.finished` reaches the gateway twice for the same `runId` for any reason (replay, reconnect duplicate, upstream resend, duplicated producer), OpenClaw currently has **no idempotency check keyed by `runId`/`contextKey`** on this path. The second copy will become a second user message with the same content.
|
||||
|
||||
## Exact Code Path
|
||||
|
||||
### 1. Producer: node exec completion event
|
||||
|
||||
- `src/node-host/invoke.ts:340-360`
|
||||
- `sendExecFinishedEvent(...)` emits `node.event` with event `exec.finished`.
|
||||
- Payload includes `sessionKey` and full `runId`.
|
||||
|
||||
### 2. Gateway event ingestion
|
||||
|
||||
- `src/gateway/server-node-events.ts:574-640`
|
||||
- Handles `exec.finished`.
|
||||
- Builds text:
|
||||
- `Exec finished (node=..., id=<runId>, code ...)`
|
||||
- Enqueues it via:
|
||||
- `enqueueSystemEvent(text, { sessionKey, contextKey: runId ? \`exec:${runId}\` : "exec", trusted: false })`
|
||||
- Immediately requests a wake:
|
||||
- `requestHeartbeatNow(scopedHeartbeatWakeOptions(sessionKey, { reason: "exec-event" }))`
|
||||
|
||||
### 3. System event dedupe weakness
|
||||
|
||||
- `src/infra/system-events.ts:90-115`
|
||||
- `enqueueSystemEvent(...)` only suppresses **consecutive duplicate text**:
|
||||
- `if (entry.lastText === cleaned) return false`
|
||||
- It stores `contextKey`, but does **not** use `contextKey` for idempotency.
|
||||
- After drain, duplicate suppression resets.
|
||||
|
||||
This means a replayed `exec.finished` with the same `runId` can be accepted again later, even though the code already had a stable idempotency candidate (`exec:<runId>`).
|
||||
|
||||
### 4. Wake handling is not the primary duplicator
|
||||
|
||||
- `src/infra/heartbeat-wake.ts:79-117`
|
||||
- Wakes are coalesced by `(agentId, sessionKey)`.
|
||||
- Duplicate wake requests for the same target collapse to one pending wake entry.
|
||||
|
||||
This makes **duplicate wake handling alone** a weaker explanation than duplicate event ingestion.
|
||||
|
||||
### 5. Heartbeat consumes the event and turns it into prompt input
|
||||
|
||||
- `src/infra/heartbeat-runner.ts:535-574`
|
||||
- Preflight peeks pending system events and classifies exec-event runs.
|
||||
- `src/auto-reply/reply/session-system-events.ts:86-90`
|
||||
- `drainFormattedSystemEvents(...)` drains the queue for the session.
|
||||
- `src/auto-reply/reply/get-reply-run.ts:400-427`
|
||||
- The drained system event block is prepended into the agent prompt body.
|
||||
|
||||
### 6. Transcript injection point
|
||||
|
||||
- `src/agents/pi-embedded-runner/run/attempt.ts:2000-2017`
|
||||
- `activeSession.prompt(effectivePrompt)` submits the full prompt to the embedded PI session.
|
||||
- That is the point where the completion-derived prompt becomes a persisted user turn.
|
||||
|
||||
So once the same system event is rebuilt into the prompt twice, duplicate LCM user messages are expected.
|
||||
|
||||
## Why plain outbound delivery retry is less likely
|
||||
|
||||
There is a real outbound failure path in the heartbeat runner:
|
||||
|
||||
- `src/infra/heartbeat-runner.ts:1194-1242`
|
||||
- The reply is generated first.
|
||||
- Outbound delivery happens later via `deliverOutboundPayloads(...)`.
|
||||
- Failure there returns `{ status: "failed" }`.
|
||||
|
||||
However, for the same system event queue entry, this alone is **not sufficient** to explain the duplicate user turns:
|
||||
|
||||
- `src/auto-reply/reply/session-system-events.ts:86-90`
|
||||
- The system event queue is already drained before outbound delivery.
|
||||
|
||||
So a channel send retry by itself would not recreate the exact same queued event. It could explain missing/failed external delivery, but not by itself a second identical session user message.
|
||||
|
||||
## Secondary, lower-confidence possibility
|
||||
|
||||
There is a full-run retry loop in the agent runner:
|
||||
|
||||
- `src/auto-reply/reply/agent-runner-execution.ts:741-1473`
|
||||
- Certain transient failures can retry the whole run and resubmit the same `commandBody`.
|
||||
|
||||
That can duplicate a persisted user prompt **within the same reply execution** if the prompt was already appended before the retry condition triggered.
|
||||
|
||||
I rank this lower than duplicate `exec.finished` ingestion because:
|
||||
|
||||
- the observed gap was around 51 seconds, which looks more like a second wake/turn than an in-process retry;
|
||||
- the report already mentions repeated message send failures, which points more toward a separate later turn than an immediate model/runtime retry.
|
||||
|
||||
## Root Cause Hypothesis
|
||||
|
||||
Highest-confidence hypothesis:
|
||||
|
||||
- The `keen-nexus` completion came through the **node exec event path**.
|
||||
- The same `exec.finished` was delivered to `server-node-events` twice.
|
||||
- Gateway accepted both because `enqueueSystemEvent(...)` does not dedupe by `contextKey` / `runId`.
|
||||
- Each accepted event triggered a heartbeat and was injected as a user turn into the PI transcript.
|
||||
|
||||
## Proposed Tiny Surgical Fix
|
||||
|
||||
If a fix is wanted, the smallest high-value change is:
|
||||
|
||||
- make exec/system-event idempotency honor `contextKey` for a short horizon, at least for exact `(sessionKey, contextKey, text)` repeats;
|
||||
- or add a dedicated dedupe in `server-node-events` for `exec.finished` keyed by `(sessionKey, runId, event kind)`.
|
||||
|
||||
That would directly block replayed `exec.finished` duplicates before they become session turns.
|
||||
@@ -51,15 +51,6 @@ OpenClaw has three public release lanes:
|
||||
- This split is intentional: keep the real npm release path short,
|
||||
deterministic, and artifact-focused, while slower live checks stay in their
|
||||
own lane so they do not stall or block publish
|
||||
- Use [Testing CI Policy](/reference/testing-ci-policy) as the source of truth
|
||||
for which end-to-end and live suites belong in `PR CI`, `release CI`,
|
||||
`scheduled CI`, or `manual only`.
|
||||
- Read that split literally:
|
||||
- the publish workflow is the short path that prepares and promotes artifacts
|
||||
- other important end-to-end checks can still be required CI in release or
|
||||
scheduled workflows
|
||||
- In other words, "not in the publish workflow" does not mean "manual only."
|
||||
It often means "run it in a different CI lane."
|
||||
- Release checks must be dispatched from the `main` workflow ref so the
|
||||
workflow logic and secrets stay canonical
|
||||
- That workflow accepts either an existing release tag or the current full
|
||||
|
||||
@@ -8,7 +8,6 @@ title: "Tests"
|
||||
# Tests
|
||||
|
||||
- Full testing kit (suites, live, Docker): [Testing](/help/testing)
|
||||
- CI placement for end-to-end and live suites: [Testing CI Policy](/reference/testing-ci-policy)
|
||||
|
||||
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
|
||||
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
|
||||
@@ -29,9 +28,9 @@ title: "Tests"
|
||||
- `pnpm test:perf:profile:main`: writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`).
|
||||
- `pnpm test:perf:profile:runner`: writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
|
||||
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. This is normal CI coverage, not just a local debugging command.
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. These tests are often too noisy or expensive for PR CI, but that usually means "release or scheduled CI," not "skip CI."
|
||||
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`) and pulls an external Open WebUI image. Treat it as a release or scheduled CI compatibility check rather than as a blocking PR lane.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
|
||||
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
|
||||
- `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits.
|
||||
|
||||
## Local PR gate
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
summary: "Source of truth for where end-to-end and live tests belong in CI"
|
||||
read_when:
|
||||
- Deciding whether an end-to-end or live suite belongs in CI
|
||||
- Adding or moving Docker, release, or live-provider coverage
|
||||
title: "Testing CI Policy"
|
||||
---
|
||||
|
||||
# Testing CI Policy
|
||||
|
||||
This page is the source of truth for where OpenClaw end-to-end and live suites
|
||||
belong.
|
||||
|
||||
Use this page to answer one practical question: when we have a real-world test,
|
||||
where should it run?
|
||||
|
||||
Work through the questions in this order:
|
||||
|
||||
1. Do we need this test to protect users from a real regression?
|
||||
2. If yes, where should it run: on PRs, before releases, on a schedule, or
|
||||
only by hand?
|
||||
3. If it runs in CI, should it fail the lane or just report problems?
|
||||
|
||||
The mistake to avoid is simple: a test can be important enough to run in CI
|
||||
without being important enough to block every PR or to sit inside the publish
|
||||
workflow.
|
||||
|
||||
Example:
|
||||
|
||||
- A live provider test may be too slow, flaky, or expensive for normal PR CI.
|
||||
- That does not make it a manual-only test.
|
||||
- It usually means the test belongs in release CI or scheduled CI instead.
|
||||
|
||||
## CI lanes
|
||||
|
||||
- `PR CI`: runs on pull requests or push validation when the touched surface
|
||||
needs it. Use this for fast, high-signal checks that should catch regressions
|
||||
before merge.
|
||||
- `Release CI`: runs before a release in a dedicated workflow lane. It may be
|
||||
blocking or non-blocking, but it is still required CI. Use this for important
|
||||
install, upgrade, compatibility, and provider checks that are too heavy for
|
||||
normal PR workflows.
|
||||
- `Scheduled CI`: runs on a timer or on-demand to catch drift in providers,
|
||||
third-party integrations, or long-running compatibility paths. Use this when
|
||||
you want ongoing coverage but do not want every PR or release to wait on it.
|
||||
- `Manual only`: keep for debug, hardware-specific, or operator-driven VM work.
|
||||
Do not put a suite here just because it is slower than a unit test.
|
||||
|
||||
## End-to-end and live matrix
|
||||
|
||||
| Suite | What it proves | Expected CI lane | Blocking guidance |
|
||||
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `pnpm test` | Core unit, integration, and routed repo test coverage | `PR CI` | Blocking |
|
||||
| `pnpm test:install:smoke` | Install script smoke plus packed tarball size checks | `PR CI` when relevant; `release CI` before tags | Blocking |
|
||||
| `pnpm test:e2e` | Real gateway WS/HTTP/node pairing behavior | `PR CI` when gateway or pairing changes | Blocking when relevant |
|
||||
| `pnpm test:docker:onboard` | Interactive onboarding wizard, config creation, gateway startup, health | `PR CI` when onboarding/setup changes; otherwise `release CI` | Blocking when relevant |
|
||||
| `pnpm test:docker:gateway-network` | Two-container gateway auth and health path | `PR CI` when gateway/network transport changes; otherwise `release CI` | Blocking when relevant |
|
||||
| `pnpm test:docker:mcp-channels` | Real `openclaw mcp serve` bridge, routing, transcripts, notifications | `PR CI` when MCP/channel bridge surfaces change; otherwise `release CI` | Blocking when relevant |
|
||||
| `pnpm test:docker:plugins` | Plugin install, `/plugin` alias behavior, restart semantics | `PR CI` when plugin runtime or install surfaces change; otherwise `release CI` | Blocking when relevant |
|
||||
| `pnpm test:docker:doctor-switch` | Repair and daemon switching between git and npm installs | `release CI` | Blocking for release work that touches install or doctor flows |
|
||||
| `pnpm test:docker:qr` | QR runtime compatibility under supported Docker Node versions | `release CI` | Usually non-blocking, but still required CI |
|
||||
| `pnpm test:install:e2e` | Full installer path with real onboarding-style flow in Docker | `release CI` | Required CI; may live outside the publish workflow |
|
||||
| `OpenClaw Cross-OS Release Checks` workflow | Fresh install, packaged upgrade, installer fresh, dev update across macOS, Windows, Linux | `release CI` | Required CI; keep separate from the publish workflow |
|
||||
| Native Discord roundtrip in cross-OS release checks | Real Discord send/readback after install or update | `release CI` | Usually non-blocking, but still required CI when enabled |
|
||||
| `pnpm test:docker:openwebui` | OpenClaw behind Open WebUI with a real proxied chat | `release CI` and `scheduled CI` | Non-blocking is fine; do not drop it from CI |
|
||||
| `pnpm test:live` | Real provider/model behavior with live credentials | `scheduled CI` and `release CI` when provider risk matters | Non-blocking is fine; do not make "not CI-stable" mean manual-only |
|
||||
| `pnpm test:docker:live-models` and `pnpm test:docker:live-gateway` | Live provider coverage inside repo Docker images | `scheduled CI` and `release CI` when provider/gateway risk matters | Non-blocking is fine |
|
||||
| `pnpm test:docker:live-cli-backend` | Real CLI backend compatibility inside Docker | `scheduled CI` | Non-blocking is fine |
|
||||
| `pnpm test:docker:live-acp-bind` | ACP bind compatibility against real agent backends | `scheduled CI` | Non-blocking is fine |
|
||||
| `pnpm test:docker:live-codex-harness` | Codex app-server harness compatibility | `scheduled CI` | Non-blocking is fine |
|
||||
| `test:parallels:*` | VM-specific host/guest install and upgrade smoke | `manual only` unless a dedicated VM CI lane exists | Manual/operator lane |
|
||||
|
||||
## Change policy
|
||||
|
||||
When you add or move an end-to-end or live suite:
|
||||
|
||||
1. Update this matrix in the same PR.
|
||||
2. Update the owning workflow or add the missing lane.
|
||||
3. Update any release or maintainer docs that point to the suite.
|
||||
|
||||
If current workflows lag behind this matrix, treat that as follow-up work to
|
||||
close rather than as permission to quietly downgrade the suite to manual-only.
|
||||
@@ -16,12 +16,9 @@ OpenAI-style models average ~4 characters per token for English text.
|
||||
OpenClaw assembles its own system prompt on every run. It includes:
|
||||
|
||||
- Tool list + short descriptions
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`).
|
||||
The compact skills block is bounded by `skills.limits.maxSkillsPromptChars`,
|
||||
with optional per-agent override at
|
||||
`agents.list[].skillsLimits.maxSkillsPromptChars`.
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`)
|
||||
- Self-update instructions
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
@@ -39,18 +36,6 @@ Everything the model receives counts toward the context limit:
|
||||
- Compaction summaries and pruning artifacts
|
||||
- Provider wrappers or safety headers (not visible, but still counted)
|
||||
|
||||
Some runtime-heavy surfaces have their own explicit caps:
|
||||
|
||||
- `agents.defaults.contextLimits.memoryGetMaxChars`
|
||||
- `agents.defaults.contextLimits.memoryGetDefaultLines`
|
||||
- `agents.defaults.contextLimits.toolResultMaxChars`
|
||||
- `agents.defaults.contextLimits.postCompactionMaxChars`
|
||||
|
||||
Per-agent overrides live under `agents.list[].contextLimits`. These knobs are
|
||||
for bounded runtime excerpts and injected runtime-owned blocks. They are
|
||||
separate from bootstrap limits, startup-context limits, and skills prompt
|
||||
limits.
|
||||
|
||||
For images, OpenClaw downscales transcript/tool image payloads before provider calls.
|
||||
Use `agents.defaults.imageMaxDimensionPx` (default: `1200`) to tune this:
|
||||
|
||||
|
||||
@@ -9,13 +9,12 @@ title: "Text-to-Speech"
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Google Gemini, Microsoft, MiniMax, or OpenAI.
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, MiniMax, or OpenAI.
|
||||
It works anywhere OpenClaw can send audio.
|
||||
|
||||
## Supported services
|
||||
|
||||
- **ElevenLabs** (primary or fallback provider)
|
||||
- **Google Gemini** (primary or fallback provider; uses Gemini API TTS)
|
||||
- **Microsoft** (primary or fallback provider; current bundled implementation uses `node-edge-tts`)
|
||||
- **MiniMax** (primary or fallback provider; uses the T2A v2 API)
|
||||
- **OpenAI** (primary or fallback provider; also used for summaries)
|
||||
@@ -35,10 +34,9 @@ or ElevenLabs.
|
||||
|
||||
## Optional keys
|
||||
|
||||
If you want OpenAI, ElevenLabs, Google Gemini, or MiniMax:
|
||||
If you want OpenAI, ElevenLabs, or MiniMax:
|
||||
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `GEMINI_API_KEY` (or `GOOGLE_API_KEY`)
|
||||
- `MINIMAX_API_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
@@ -172,32 +170,6 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Google Gemini primary
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
tts: {
|
||||
auto: "always",
|
||||
provider: "google",
|
||||
providers: {
|
||||
google: {
|
||||
apiKey: "gemini_api_key",
|
||||
model: "gemini-3.1-flash-tts-preview",
|
||||
voiceName: "Kore",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Google Gemini TTS uses the Gemini API key path. A Google Cloud Console API key
|
||||
restricted to the Gemini API is valid here, and it is the same style of key used
|
||||
by the bundled Google image-generation provider. Resolution order is
|
||||
`messages.tts.providers.google.apiKey` -> `models.providers.google.apiKey` ->
|
||||
`GEMINI_API_KEY` -> `GOOGLE_API_KEY`.
|
||||
|
||||
### Disable Microsoft speech
|
||||
|
||||
```json5
|
||||
@@ -266,7 +238,7 @@ Then run:
|
||||
- `tagged` only sends audio when the reply includes `[[tts:key=value]]` directives or a `[[tts:text]]...[[/tts:text]]` block.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
- `provider`: speech provider id such as `"elevenlabs"`, `"google"`, `"microsoft"`, `"minimax"`, or `"openai"` (fallback is automatic).
|
||||
- `provider`: speech provider id such as `"elevenlabs"`, `"microsoft"`, `"minimax"`, or `"openai"` (fallback is automatic).
|
||||
- If `provider` is **unset**, OpenClaw uses the first configured speech provider in registry auto-select order.
|
||||
- Legacy `provider: "edge"` still works and is normalized to `microsoft`.
|
||||
- `summaryModel`: optional cheap model for auto-summary; defaults to `agents.defaults.model.primary`.
|
||||
@@ -278,7 +250,7 @@ Then run:
|
||||
- `maxTextLength`: hard cap for TTS input (chars). `/tts audio` fails if exceeded.
|
||||
- `timeoutMs`: request timeout (ms).
|
||||
- `prefsPath`: override the local prefs JSON path (provider/limit/summary).
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `GEMINI_API_KEY`/`GOOGLE_API_KEY`, `MINIMAX_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `apiKey` values fall back to env vars (`ELEVENLABS_API_KEY`/`XI_API_KEY`, `MINIMAX_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `providers.elevenlabs.baseUrl`: override ElevenLabs API base URL.
|
||||
- `providers.openai.baseUrl`: override the OpenAI TTS endpoint.
|
||||
- Resolution order: `messages.tts.providers.openai.baseUrl` -> `OPENAI_TTS_BASE_URL` -> `https://api.openai.com/v1`
|
||||
@@ -296,10 +268,6 @@ Then run:
|
||||
- `providers.minimax.speed`: playback speed `0.5..2.0` (default 1.0).
|
||||
- `providers.minimax.vol`: volume `(0, 10]` (default 1.0; must be greater than 0).
|
||||
- `providers.minimax.pitch`: pitch shift `-12..12` (default 0).
|
||||
- `providers.google.model`: Gemini TTS model (default `gemini-3.1-flash-tts-preview`).
|
||||
- `providers.google.voiceName`: Gemini prebuilt voice name (default `Kore`; `voice` is also accepted).
|
||||
- `providers.google.baseUrl`: override the Gemini API base URL. Only `https://generativelanguage.googleapis.com` is accepted.
|
||||
- If `messages.tts.providers.google.apiKey` is omitted, TTS can reuse `models.providers.google.apiKey` before env fallback.
|
||||
- `providers.microsoft.enabled`: allow Microsoft speech usage (default `true`; no API key).
|
||||
- `providers.microsoft.voice`: Microsoft neural voice name (e.g. `en-US-MichelleNeural`).
|
||||
- `providers.microsoft.lang`: language code (e.g. `en-US`).
|
||||
@@ -334,9 +302,9 @@ Here you go.
|
||||
|
||||
Available directive keys (when enabled):
|
||||
|
||||
- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, `google`, `minimax`, or `microsoft`; requires `allowProvider: true`)
|
||||
- `voice` (OpenAI voice), `voiceName` / `voice_name` / `google_voice` (Google voice), or `voiceId` (ElevenLabs / MiniMax)
|
||||
- `model` (OpenAI TTS model, ElevenLabs model id, or MiniMax model) or `google_model` (Google TTS model)
|
||||
- `provider` (registered speech provider id, for example `openai`, `elevenlabs`, `minimax`, or `microsoft`; requires `allowProvider: true`)
|
||||
- `voice` (OpenAI voice) or `voiceId` (ElevenLabs / MiniMax)
|
||||
- `model` (OpenAI TTS model, ElevenLabs model id, or MiniMax model)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
- `vol` / `volume` (MiniMax volume, 0-10)
|
||||
- `pitch` (MiniMax pitch, -12 to 12)
|
||||
@@ -396,7 +364,6 @@ These override `messages.tts.*` for that host.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **MiniMax**: MP3 (`speech-2.8-hd` model, 32kHz sample rate). Voice-note format not natively supported; use OpenAI or ElevenLabs for guaranteed Opus voice messages.
|
||||
- **Google Gemini**: Gemini API TTS returns raw 24kHz PCM. OpenClaw wraps it as WAV for audio attachments and returns PCM directly for Talk/telephony. Native Opus voice-note format is not supported by this path.
|
||||
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
|
||||
|
||||
@@ -101,11 +101,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
primary: "anthropic/claude-sonnet-4-6",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
@@ -114,17 +114,16 @@ describe("anthropic cli migration", () => {
|
||||
});
|
||||
|
||||
expect(result.profiles).toEqual([]);
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7");
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "claude-cli/claude-opus-4-7",
|
||||
primary: "claude-cli/claude-sonnet-4-6",
|
||||
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-7": { alias: "Opus" },
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"claude-cli/claude-opus-4-6": { alias: "Opus" },
|
||||
"claude-cli/claude-opus-4-5": {},
|
||||
"claude-cli/claude-sonnet-4-5": {},
|
||||
@@ -148,13 +147,12 @@ describe("anthropic cli migration", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7");
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.2": {},
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"claude-cli/claude-opus-4-5": {},
|
||||
@@ -170,9 +168,9 @@ describe("anthropic cli migration", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-opus-4-7" },
|
||||
model: { primary: "claude-cli/claude-sonnet-4-6" },
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -182,7 +180,6 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"claude-cli/claude-opus-4-5": {},
|
||||
@@ -220,11 +217,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
primary: "anthropic/claude-sonnet-4-6",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
@@ -300,11 +297,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
primary: "anthropic/claude-sonnet-4-6",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
@@ -318,11 +315,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "claude-cli/claude-opus-4-7",
|
||||
primary: "claude-cli/claude-sonnet-4-6",
|
||||
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-7": { alias: "Opus" },
|
||||
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"claude-cli/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
|
||||
@@ -2,10 +2,9 @@ import type { CliBackendConfig } from "openclaw/plugin-sdk/cli-backend";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export const CLAUDE_CLI_BACKEND_ID = "claude-cli";
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`;
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`;
|
||||
export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
|
||||
CLAUDE_CLI_DEFAULT_MODEL_REF,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-6`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-5`,
|
||||
`${CLAUDE_CLI_BACKEND_ID}/claude-sonnet-4-5`,
|
||||
@@ -14,11 +13,9 @@ export const CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS = [
|
||||
|
||||
export const CLAUDE_CLI_MODEL_ALIASES: Record<string, string> = {
|
||||
opus: "opus",
|
||||
"opus-4.7": "opus",
|
||||
"opus-4.6": "opus",
|
||||
"opus-4.5": "opus",
|
||||
"opus-4": "opus",
|
||||
"claude-opus-4-7": "opus",
|
||||
"claude-opus-4-6": "opus",
|
||||
"claude-opus-4-5": "opus",
|
||||
"claude-opus-4": "opus",
|
||||
|
||||
@@ -87,7 +87,7 @@ function resolveAnthropicPrimaryModelRef(raw?: string): string | null {
|
||||
}
|
||||
const aliasKey = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (aliasKey === "opus") {
|
||||
return "anthropic/claude-opus-4-7";
|
||||
return "anthropic/claude-opus-4-6";
|
||||
}
|
||||
if (aliasKey === "sonnet") {
|
||||
return "anthropic/claude-sonnet-4-6";
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { capturePluginRegistration } from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/plugins/plugin-registration.js";
|
||||
@@ -22,19 +18,6 @@ vi.mock("./cli-auth-seam.js", () => {
|
||||
|
||||
import anthropicPlugin from "./index.js";
|
||||
|
||||
function createModelRegistry(models: ProviderRuntimeModel[]) {
|
||||
return {
|
||||
find(providerId: string, modelId: string) {
|
||||
return (
|
||||
models.find(
|
||||
(model) =>
|
||||
model.provider === providerId && model.id.toLowerCase() === modelId.toLowerCase(),
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("anthropic provider replay hooks", () => {
|
||||
it("registers the claude-cli backend", async () => {
|
||||
const captured = capturePluginRegistration({ register: anthropicPlugin.register });
|
||||
@@ -146,9 +129,9 @@ describe("anthropic provider replay hooks", () => {
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-opus-4-7" },
|
||||
model: { primary: "claude-cli/claude-sonnet-4-6" },
|
||||
models: {
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -159,7 +142,6 @@ describe("anthropic provider replay hooks", () => {
|
||||
every: "1h",
|
||||
});
|
||||
expect(next?.agents?.defaults?.models).toMatchObject({
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-opus-4-6": {},
|
||||
"claude-cli/claude-opus-4-5": {},
|
||||
@@ -168,40 +150,6 @@ describe("anthropic provider replay hooks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves explicit claude-opus-4-7 refs from the 4.6 template family", async () => {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const resolved = provider.resolveDynamicModel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
modelRegistry: createModelRegistry([
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 32_000,
|
||||
} as ProviderRuntimeModel,
|
||||
]),
|
||||
} as ProviderResolveDynamicModelContext);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
provider: "anthropic",
|
||||
id: "claude-opus-4-7",
|
||||
api: "anthropic-messages",
|
||||
reasoning: true,
|
||||
});
|
||||
expect(
|
||||
provider.resolveDefaultThinkingLevel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
} as never),
|
||||
).toBe("adaptive");
|
||||
});
|
||||
|
||||
it("resolves claude-cli synthetic oauth auth", async () => {
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReset();
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
export const anthropicMediaUnderstandingProvider: MediaUnderstandingProvider = {
|
||||
id: "anthropic",
|
||||
capabilities: ["image"],
|
||||
defaultModels: { image: "claude-opus-4-7" },
|
||||
defaultModels: { image: "claude-opus-4-6" },
|
||||
autoPriority: { image: 20 },
|
||||
nativeDocumentInputs: ["pdf"],
|
||||
describeImage: describeImageWithModel,
|
||||
|
||||
@@ -38,23 +38,14 @@ import { buildAnthropicReplayPolicy } from "./replay-policy.js";
|
||||
import { wrapAnthropicProviderStream } from "./stream-wrappers.js";
|
||||
|
||||
const PROVIDER_ID = "anthropic";
|
||||
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-opus-4-7";
|
||||
const ANTHROPIC_OPUS_47_MODEL_ID = "claude-opus-4-7";
|
||||
const ANTHROPIC_OPUS_47_DOT_MODEL_ID = "claude-opus-4.7";
|
||||
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
|
||||
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
|
||||
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
|
||||
const ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS = [
|
||||
ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4.5",
|
||||
] as const;
|
||||
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
|
||||
const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
|
||||
const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const;
|
||||
const ANTHROPIC_MODERN_MODEL_PREFIXES = [
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-5",
|
||||
@@ -230,14 +221,6 @@ function resolveAnthropicForwardCompatModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
return (
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_OPUS_47_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_OPUS_47_DOT_MODEL_ID,
|
||||
dashTemplateId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
dotTemplateId: ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
fallbackTemplateIds: ANTHROPIC_OPUS_47_TEMPLATE_MODEL_IDS,
|
||||
}) ??
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
@@ -260,8 +243,6 @@ function resolveAnthropicForwardCompatModel(
|
||||
function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
|
||||
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return (
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
|
||||
@@ -391,7 +372,7 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
|
||||
const defaultAnthropicModel = "anthropic/claude-sonnet-4-6";
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
|
||||
@@ -428,13 +428,9 @@ describe("runBlueBubblesCatchup", () => {
|
||||
// Cursor is held just before the bad message's timestamp so the next
|
||||
// sweep retries it (and re-queries ok1 which dedupe will drop).
|
||||
expect(summary?.failed).toBe(1);
|
||||
expect(summary?.givenUp).toBe(0);
|
||||
expect(summary?.cursorAfter).toBe(7 * 60 * 1000 - 1);
|
||||
const cursorAfter = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursorAfter?.lastSeenMs).toBe(7 * 60 * 1000 - 1);
|
||||
// Retry counter is persisted so subsequent sweeps know how close we
|
||||
// are to the give-up ceiling.
|
||||
expect(cursorAfter?.failureRetries?.bad).toBe(1);
|
||||
});
|
||||
|
||||
it("clamps held cursor to previous cursor when failure ts is below it", async () => {
|
||||
@@ -610,494 +606,6 @@ describe("runBlueBubblesCatchup", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runBlueBubblesCatchup — per-message retry cap", () => {
|
||||
let stateDir: string;
|
||||
beforeEach(() => {
|
||||
stateDir = makeStateDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStateDir(stateDir);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("increments retry counter on each consecutive failure and holds cursor", async () => {
|
||||
// Three sweeps, all fail on the same GUID. Counter accumulates and
|
||||
// cursor stays pinned below the failing message so every sweep
|
||||
// retries it. maxFailureRetries: 5 so we don't give up inside this
|
||||
// test.
|
||||
const now1 = 10 * 60 * 1000;
|
||||
const now2 = now1 + 60 * 1000;
|
||||
const now3 = now2 + 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
|
||||
const target = makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxFailureRetries: 5 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
});
|
||||
|
||||
const fetchMessages = async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 })],
|
||||
});
|
||||
const processMessageFn = async () => {
|
||||
throw new Error("boom");
|
||||
};
|
||||
|
||||
const s1 = await runBlueBubblesCatchup(target, {
|
||||
now: () => now1,
|
||||
fetchMessages,
|
||||
processMessageFn,
|
||||
});
|
||||
const s2 = await runBlueBubblesCatchup(target, {
|
||||
now: () => now2,
|
||||
fetchMessages,
|
||||
processMessageFn,
|
||||
});
|
||||
const s3 = await runBlueBubblesCatchup(target, {
|
||||
now: () => now3,
|
||||
fetchMessages,
|
||||
processMessageFn,
|
||||
});
|
||||
|
||||
expect(s1?.failed).toBe(1);
|
||||
expect(s1?.givenUp).toBe(0);
|
||||
expect(s2?.givenUp).toBe(0);
|
||||
expect(s3?.givenUp).toBe(0);
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursor?.failureRetries?.wedge).toBe(3);
|
||||
// Cursor still held just below the wedge message's timestamp.
|
||||
expect(cursor?.lastSeenMs).toBe(7 * 60 * 1000 - 1);
|
||||
});
|
||||
|
||||
it("gives up on the Nth consecutive failure and records count >= max", async () => {
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
// Pre-seed a cursor with retries at the one-before-give-up threshold
|
||||
// so a single run trips the ceiling. This mirrors what would happen
|
||||
// after many runs through the incremental-retry path above.
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { wedge: 2 });
|
||||
|
||||
const warnings: string[] = [];
|
||||
const target = makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxFailureRetries: 3 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await runBlueBubblesCatchup(target, {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 })],
|
||||
}),
|
||||
processMessageFn: async () => {
|
||||
throw new Error("malformed");
|
||||
},
|
||||
error: (m) => warnings.push(m),
|
||||
});
|
||||
|
||||
expect(summary?.failed).toBe(1);
|
||||
expect(summary?.givenUp).toBe(1);
|
||||
// Give-up no longer holds the cursor: it advances to nowMs so the
|
||||
// wedge message falls out of the next query window entirely.
|
||||
expect(summary?.cursorAfter).toBe(now);
|
||||
|
||||
const persisted = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(persisted?.lastSeenMs).toBe(now);
|
||||
// Counter is persisted at the give-up value so a later sweep that
|
||||
// still sees the message (e.g., because a different GUID is holding
|
||||
// the cursor) will recognize the GUID as given up and skip it.
|
||||
expect(persisted?.failureRetries?.wedge).toBe(3);
|
||||
|
||||
// Distinct WARN log line fired on the give-up transition.
|
||||
const giveUpWarnings = warnings.filter((w) => w.includes("giving up on guid="));
|
||||
expect(giveUpWarnings).toHaveLength(1);
|
||||
expect(giveUpWarnings[0]).toContain("guid=wedge");
|
||||
expect(giveUpWarnings[0]).toContain("3 consecutive failures");
|
||||
});
|
||||
|
||||
it("skips an already-given-up GUID without re-attempting processMessage", async () => {
|
||||
// Setup: the cursor file was written with wedge already at the
|
||||
// give-up threshold from a prior run. On this run, the cursor is
|
||||
// held by a different, still-retrying GUID (`held`), so wedge's
|
||||
// timestamp falls back into the query window. Catchup must skip
|
||||
// wedge without invoking processMessage on it.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { wedge: 3 });
|
||||
|
||||
const attempted: string[] = [];
|
||||
const target = makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxFailureRetries: 3 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await runBlueBubblesCatchup(target, {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "held", dateCreated: 6 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async (m) => {
|
||||
attempted.push(m.messageId ?? "?");
|
||||
if (m.messageId === "held") {
|
||||
throw new Error("transient");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// processMessage never runs for wedge.
|
||||
expect(attempted).toEqual(["held"]);
|
||||
expect(summary?.skippedGivenUp).toBe(1);
|
||||
expect(summary?.failed).toBe(1);
|
||||
expect(summary?.givenUp).toBe(0);
|
||||
// Cursor held at `held` so held keeps retrying next sweep.
|
||||
expect(summary?.cursorAfter).toBe(6 * 60 * 1000 - 1);
|
||||
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
// Both entries preserved: held at count 1 (still retrying),
|
||||
// wedge at count 3 (given up, sticky).
|
||||
expect(cursor?.failureRetries?.held).toBe(1);
|
||||
expect(cursor?.failureRetries?.wedge).toBe(3);
|
||||
});
|
||||
|
||||
it("clears the retry counter on successful processing", async () => {
|
||||
// GUID recovered after a transient failure. The counter must drop
|
||||
// so the next failure starts fresh (not carrying forward stale
|
||||
// retry history).
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { flaky: 4 });
|
||||
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "flaky", dateCreated: 6 * 60 * 1000 })],
|
||||
}),
|
||||
processMessageFn: async () => {
|
||||
/* succeeds */
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.replayed).toBe(1);
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursor?.failureRetries?.flaky).toBeUndefined();
|
||||
// When the map is empty, the field itself is omitted from the file.
|
||||
expect(cursor?.failureRetries).toBeUndefined();
|
||||
expect(cursor?.lastSeenMs).toBe(now);
|
||||
});
|
||||
|
||||
it("resolves 'earlier retry + later give-up' by holding cursor at earlier and skipping later", async () => {
|
||||
// This is the key scenario issue #66870 exists to solve. GUID A at
|
||||
// t=6min is still retrying (count=1). GUID B at t=7min has been
|
||||
// failing for many runs and crosses the ceiling on this run. The
|
||||
// wrong answer is "advance cursor past B to t=7min" — that would
|
||||
// lose A. The right answer is "hold cursor below A, record B as
|
||||
// given-up, skip B on sight next run".
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { giveUpHere: 2 });
|
||||
|
||||
const target = makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxFailureRetries: 3 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await runBlueBubblesCatchup(target, {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "retryEarlier", dateCreated: 6 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "giveUpHere", dateCreated: 7 * 60 * 1000 }),
|
||||
],
|
||||
}),
|
||||
processMessageFn: async () => {
|
||||
throw new Error("failing");
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary?.failed).toBe(2);
|
||||
expect(summary?.givenUp).toBe(1);
|
||||
// Cursor held at (earlier message ts - 1) so retryEarlier keeps retrying.
|
||||
expect(summary?.cursorAfter).toBe(6 * 60 * 1000 - 1);
|
||||
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(cursor?.failureRetries?.retryEarlier).toBe(1);
|
||||
// Give-up counter preserved at or above the threshold.
|
||||
expect(cursor?.failureRetries?.giveUpHere).toBe(3);
|
||||
});
|
||||
|
||||
it("uses the default retry cap when maxFailureRetries is omitted from config", async () => {
|
||||
// Boot-strap: record 9 failures, then a 10th should trigger give-up
|
||||
// at the default threshold. We pre-seed the counter at 9 so this
|
||||
// single-run test doesn't need to iterate the whole sequence.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, { wedge: 9 });
|
||||
|
||||
const warnings: string[] = [];
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "wedge", dateCreated: 6 * 60 * 1000 })],
|
||||
}),
|
||||
processMessageFn: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
error: (m) => warnings.push(m),
|
||||
});
|
||||
expect(summary?.givenUp).toBe(1);
|
||||
expect(warnings.some((w) => w.includes("giving up on guid=wedge"))).toBe(true);
|
||||
expect(warnings.some((w) => w.includes("10 consecutive failures"))).toBe(true);
|
||||
});
|
||||
|
||||
it("clamps maxFailureRetries to >= 1 when configured to zero or negative", async () => {
|
||||
// With clamp floor of 1, the first failure already meets count >= 1
|
||||
// so catchup gives up immediately on first attempt.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
|
||||
const summary = await runBlueBubblesCatchup(
|
||||
makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxFailureRetries: 0 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
}),
|
||||
{
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "wedge", dateCreated: 6 * 60 * 1000 })],
|
||||
}),
|
||||
processMessageFn: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(summary?.givenUp).toBe(1);
|
||||
expect(summary?.cursorAfter).toBe(now);
|
||||
});
|
||||
|
||||
it("loads cleanly from a legacy cursor file without a failureRetries field", async () => {
|
||||
// Older cursor files (written before this field existed) must still
|
||||
// parse. Round-trip: save without the field (legacy path), then
|
||||
// run catchup and confirm a normal sweep proceeds.
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000);
|
||||
const loaded = await loadBlueBubblesCatchupCursor("test-account");
|
||||
expect(loaded?.lastSeenMs).toBe(5 * 60 * 1000);
|
||||
expect(loaded?.failureRetries).toBeUndefined();
|
||||
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => 10 * 60 * 1000,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
messages: [makeBbMessage({ guid: "ok", dateCreated: 6 * 60 * 1000 })],
|
||||
}),
|
||||
processMessageFn: async () => {},
|
||||
});
|
||||
expect(summary?.replayed).toBe(1);
|
||||
});
|
||||
|
||||
it("drops retry entries for GUIDs that are no longer in the query window", async () => {
|
||||
// A stale entry carried in the cursor file (e.g., from an older
|
||||
// run whose cursor has since advanced past its timestamp) should
|
||||
// NOT be carried forward if the GUID does not appear in the
|
||||
// current fetch. Otherwise the map grows without bound over time.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, {
|
||||
staleGuid: 2,
|
||||
alsoStale: 5,
|
||||
});
|
||||
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => now,
|
||||
fetchMessages: async () => ({
|
||||
resolved: true,
|
||||
// Fetch returns entirely different GUIDs from the stored map.
|
||||
messages: [makeBbMessage({ guid: "fresh", dateCreated: 6 * 60 * 1000 })],
|
||||
}),
|
||||
processMessageFn: async () => {},
|
||||
});
|
||||
expect(summary?.replayed).toBe(1);
|
||||
const cursor = await loadBlueBubblesCatchupCursor("test-account");
|
||||
// Both stale entries dropped; no new entries since the fresh message
|
||||
// succeeded.
|
||||
expect(cursor?.failureRetries).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves stickiness when a given-up GUID reappears and fails again", async () => {
|
||||
// Setup: cursor advanced, but held by a newer still-retrying GUID
|
||||
// `held`. The wedge GUID is already given up from a prior run and
|
||||
// still appears because `held` is holding the cursor below it.
|
||||
// Catchup must continue to skip wedge on sight across many runs
|
||||
// without ever calling processMessage on it.
|
||||
const now = 10 * 60 * 1000;
|
||||
await saveBlueBubblesCatchupCursor("test-account", 5 * 60 * 1000, {
|
||||
wedge: 10,
|
||||
held: 1,
|
||||
});
|
||||
|
||||
const attempted: string[] = [];
|
||||
const target = makeTarget({
|
||||
account: {
|
||||
accountId: "test-account",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:1234",
|
||||
config: {
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "x",
|
||||
network: { dangerouslyAllowPrivateNetwork: true },
|
||||
catchup: { maxFailureRetries: 5 },
|
||||
} as unknown as WebhookTarget["account"]["config"],
|
||||
},
|
||||
});
|
||||
const fetchMessages = async () => ({
|
||||
resolved: true,
|
||||
messages: [
|
||||
makeBbMessage({ guid: "held", dateCreated: 6 * 60 * 1000 }),
|
||||
makeBbMessage({ guid: "wedge", dateCreated: 7 * 60 * 1000 }),
|
||||
],
|
||||
});
|
||||
const processMessageFn = async () => {
|
||||
throw new Error("still broken");
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await runBlueBubblesCatchup(target, {
|
||||
now: () => now + i,
|
||||
fetchMessages,
|
||||
processMessageFn: async (m) => {
|
||||
attempted.push(m.messageId ?? "?");
|
||||
return processMessageFn();
|
||||
},
|
||||
});
|
||||
}
|
||||
// wedge is NEVER attempted despite reappearing every sweep.
|
||||
expect(attempted.filter((g) => g === "wedge")).toHaveLength(0);
|
||||
// held is attempted every sweep.
|
||||
expect(attempted.filter((g) => g === "held")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("summary.skippedGivenUp counter is zero on a clean run", async () => {
|
||||
const summary = await runBlueBubblesCatchup(makeTarget(), {
|
||||
now: () => 10_000,
|
||||
fetchMessages: async () => ({ resolved: true, messages: [] }),
|
||||
processMessageFn: async () => {},
|
||||
});
|
||||
expect(summary?.skippedGivenUp).toBe(0);
|
||||
expect(summary?.givenUp).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveBlueBubblesCatchupCursor + loadBlueBubblesCatchupCursor — retry map", () => {
|
||||
let stateDir: string;
|
||||
beforeEach(() => {
|
||||
stateDir = makeStateDir();
|
||||
});
|
||||
afterEach(() => {
|
||||
clearStateDir(stateDir);
|
||||
});
|
||||
|
||||
it("round-trips an empty retry map by omitting the field from the persisted shape", async () => {
|
||||
await saveBlueBubblesCatchupCursor("acct", 100, {});
|
||||
const loaded = await loadBlueBubblesCatchupCursor("acct");
|
||||
expect(loaded?.lastSeenMs).toBe(100);
|
||||
expect(loaded?.failureRetries).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips a populated retry map", async () => {
|
||||
await saveBlueBubblesCatchupCursor("acct", 100, { a: 1, b: 9 });
|
||||
const loaded = await loadBlueBubblesCatchupCursor("acct");
|
||||
expect(loaded?.failureRetries).toEqual({ a: 1, b: 9 });
|
||||
});
|
||||
|
||||
it("filters malformed retry entries during load (zero, negative, non-numeric)", async () => {
|
||||
// Use the public save to produce the on-disk file, then overwrite
|
||||
// its contents with a hand-crafted payload to exercise the loader's
|
||||
// sanitization independently of what the saver would emit.
|
||||
await saveBlueBubblesCatchupCursor("acct", 100);
|
||||
const stateRoot = process.env.OPENCLAW_STATE_DIR;
|
||||
if (!stateRoot) {
|
||||
throw new Error("OPENCLAW_STATE_DIR must be set by the test harness");
|
||||
}
|
||||
const dir = path.join(stateRoot, "bluebubbles", "catchup");
|
||||
const files = fs.readdirSync(dir);
|
||||
expect(files).toHaveLength(1);
|
||||
const firstFile = files[0];
|
||||
if (!firstFile) {
|
||||
throw new Error("expected a cursor file to exist after save");
|
||||
}
|
||||
const badCursor = {
|
||||
lastSeenMs: 100,
|
||||
updatedAt: 0,
|
||||
failureRetries: {
|
||||
good: 3,
|
||||
zero: 0,
|
||||
negative: -1,
|
||||
notANumber: "oops",
|
||||
infinite: Number.POSITIVE_INFINITY,
|
||||
nan: Number.NaN,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, firstFile), JSON.stringify(badCursor));
|
||||
|
||||
const loaded = await loadBlueBubblesCatchupCursor("acct");
|
||||
expect(loaded?.lastSeenMs).toBe(100);
|
||||
expect(loaded?.failureRetries).toEqual({ good: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBlueBubblesMessagesSince", () => {
|
||||
it("returns resolved:false when the network call throws", async () => {
|
||||
// Point at a port nothing is listening on so fetch fails fast.
|
||||
|
||||
@@ -4,7 +4,6 @@ import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plug
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { warmupBlueBubblesInboundDedupe } from "./inbound-dedupe.js";
|
||||
import { asRecord, normalizeWebhookMessage } from "./monitor-normalize.js";
|
||||
import { processMessage } from "./monitor-processing.js";
|
||||
import type { WebhookTarget } from "./monitor-shared.js";
|
||||
@@ -22,14 +21,6 @@ const MAX_MAX_AGE_MINUTES = 12 * 60;
|
||||
const DEFAULT_PER_RUN_LIMIT = 50;
|
||||
const MAX_PER_RUN_LIMIT = 500;
|
||||
const DEFAULT_FIRST_RUN_LOOKBACK_MINUTES = 30;
|
||||
const DEFAULT_MAX_FAILURE_RETRIES = 10;
|
||||
const MAX_MAX_FAILURE_RETRIES = 1_000;
|
||||
// Defense-in-depth bound: a runaway retry map (e.g., a storm of unique
|
||||
// failing GUIDs) should not balloon the cursor file unboundedly. When the
|
||||
// map exceeds this size, we keep only the highest-count entries (the ones
|
||||
// closest to being given up) and drop the rest. Realistic backlogs stay
|
||||
// well under this; the bound exists to cap pathological growth.
|
||||
const MAX_FAILURE_RETRY_MAP_SIZE = 5_000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type BlueBubblesCatchupConfig = {
|
||||
@@ -37,13 +28,6 @@ export type BlueBubblesCatchupConfig = {
|
||||
maxAgeMinutes?: number;
|
||||
perRunLimit?: number;
|
||||
firstRunLookbackMinutes?: number;
|
||||
/**
|
||||
* Per-message retry ceiling. After this many consecutive failed
|
||||
* `processMessage` attempts against the same GUID, catchup logs a WARN
|
||||
* and force-advances the cursor past the wedged message instead of
|
||||
* holding it indefinitely. Defaults to 10. Clamped to [1, 1000].
|
||||
*/
|
||||
maxFailureRetries?: number;
|
||||
};
|
||||
|
||||
export type BlueBubblesCatchupSummary = {
|
||||
@@ -51,21 +35,7 @@ export type BlueBubblesCatchupSummary = {
|
||||
replayed: number;
|
||||
skippedFromMe: number;
|
||||
skippedPreCursor: number;
|
||||
/**
|
||||
* Messages whose GUID was already recorded as "given up" from a previous
|
||||
* run (count >= `maxFailureRetries`). These are skipped without calling
|
||||
* `processMessage` again. Lets the cursor continue advancing past the
|
||||
* wedged message on the next sweep while avoiding another failed attempt.
|
||||
*/
|
||||
skippedGivenUp: number;
|
||||
failed: number;
|
||||
/**
|
||||
* Messages that crossed the `maxFailureRetries` ceiling ON THIS RUN.
|
||||
* Each transition triggers a WARN log line. Already-given-up messages
|
||||
* in subsequent runs count under `skippedGivenUp`, not here. Lets
|
||||
* operators distinguish fresh give-up events from steady-state skips.
|
||||
*/
|
||||
givenUp: number;
|
||||
cursorBefore: number | null;
|
||||
cursorAfter: number;
|
||||
windowStartMs: number;
|
||||
@@ -73,24 +43,7 @@ export type BlueBubblesCatchupSummary = {
|
||||
fetchedCount: number;
|
||||
};
|
||||
|
||||
export type BlueBubblesCatchupCursor = {
|
||||
lastSeenMs: number;
|
||||
updatedAt: number;
|
||||
/**
|
||||
* Per-GUID failure counter, preserved across runs. Two states:
|
||||
* - `1 <= count < maxFailureRetries`: the GUID is still retrying and
|
||||
* continues to hold the cursor back.
|
||||
* - `count >= maxFailureRetries`: catchup has "given up" on the GUID.
|
||||
* The message is skipped on sight (no `processMessage` attempt) and
|
||||
* the GUID no longer holds the cursor. The entry stays in the map
|
||||
* until the cursor naturally advances past the message's timestamp
|
||||
* (at which point the message stops appearing in queries entirely).
|
||||
*
|
||||
* A successful `processMessage` removes the entry. Optional on the
|
||||
* persisted shape so older cursor files without this field load cleanly.
|
||||
*/
|
||||
failureRetries?: Record<string, number>;
|
||||
};
|
||||
export type BlueBubblesCatchupCursor = { lastSeenMs: number; updatedAt: number };
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
||||
// Explicit OPENCLAW_STATE_DIR overrides take precedence (including
|
||||
@@ -128,26 +81,6 @@ function resolveCursorFilePath(accountId: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeFailureRetriesInput(raw: unknown): Record<string, number> {
|
||||
// Older cursor files don't carry this field; also guard against
|
||||
// hand-edited JSON or future shape drift. Drop any entry whose count is
|
||||
// not a finite positive integer so downstream arithmetic stays sound.
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {};
|
||||
}
|
||||
const out: Record<string, number> = {};
|
||||
for (const [guid, count] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!guid || typeof guid !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) {
|
||||
continue;
|
||||
}
|
||||
out[guid] = Math.floor(count);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function loadBlueBubblesCatchupCursor(
|
||||
accountId: string,
|
||||
): Promise<BlueBubblesCatchupCursor | null> {
|
||||
@@ -159,66 +92,18 @@ export async function loadBlueBubblesCatchupCursor(
|
||||
if (typeof value.lastSeenMs !== "number" || !Number.isFinite(value.lastSeenMs)) {
|
||||
return null;
|
||||
}
|
||||
const failureRetries = sanitizeFailureRetriesInput(value.failureRetries);
|
||||
const hasRetries = Object.keys(failureRetries).length > 0;
|
||||
// Keep the shape consistent with what the writer emits: only carry the
|
||||
// `failureRetries` key when there's something to retry. Old cursor files
|
||||
// without the field continue to round-trip to the same shape.
|
||||
return {
|
||||
lastSeenMs: value.lastSeenMs,
|
||||
updatedAt: typeof value.updatedAt === "number" ? value.updatedAt : 0,
|
||||
...(hasRetries ? { failureRetries } : {}),
|
||||
};
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function saveBlueBubblesCatchupCursor(
|
||||
accountId: string,
|
||||
lastSeenMs: number,
|
||||
failureRetries?: Record<string, number>,
|
||||
): Promise<void> {
|
||||
const filePath = resolveCursorFilePath(accountId);
|
||||
const sanitized = sanitizeFailureRetriesInput(failureRetries);
|
||||
const hasRetries = Object.keys(sanitized).length > 0;
|
||||
const cursor: BlueBubblesCatchupCursor = {
|
||||
lastSeenMs,
|
||||
updatedAt: Date.now(),
|
||||
// Only emit the field when non-empty so unrelated cursor writes from
|
||||
// the happy path don't bloat the cursor file with `"failureRetries": {}`.
|
||||
...(hasRetries ? { failureRetries: sanitized } : {}),
|
||||
};
|
||||
const cursor: BlueBubblesCatchupCursor = { lastSeenMs, updatedAt: Date.now() };
|
||||
await writeJsonFileAtomically(filePath, cursor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound the retry map so a pathological storm of unique failing GUIDs
|
||||
* cannot grow the cursor file without limit. Keeps the `maxSize` entries
|
||||
* with the highest counts (closest to give-up) when over the bound.
|
||||
*
|
||||
* The map is already scoped to "currently failing, still-retrying" GUIDs
|
||||
* and prunes on every run (entries not observed in the fetched window are
|
||||
* dropped), so this is a defense-in-depth cap, not the primary pruning
|
||||
* mechanism.
|
||||
*/
|
||||
function capFailureRetriesMap(
|
||||
map: Record<string, number>,
|
||||
maxSize: number,
|
||||
): Record<string, number> {
|
||||
const entries = Object.entries(map);
|
||||
if (entries.length <= maxSize) {
|
||||
return map;
|
||||
}
|
||||
// Sort by count desc; stable tiebreak on guid string so the retained set
|
||||
// is deterministic across runs (important for cursor-file diffing during
|
||||
// debugging).
|
||||
entries.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
const capped: Record<string, number> = {};
|
||||
for (let i = 0; i < maxSize; i++) {
|
||||
const [guid, count] = entries[i];
|
||||
capped[guid] = count;
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
type FetchOpts = {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
@@ -295,15 +180,10 @@ function clampCatchupConfig(raw?: BlueBubblesCatchupConfig) {
|
||||
Math.max(raw?.firstRunLookbackMinutes ?? DEFAULT_FIRST_RUN_LOOKBACK_MINUTES, 1),
|
||||
MAX_MAX_AGE_MINUTES,
|
||||
);
|
||||
const maxFailureRetries = Math.min(
|
||||
Math.max(Math.floor(raw?.maxFailureRetries ?? DEFAULT_MAX_FAILURE_RETRIES), 1),
|
||||
MAX_MAX_FAILURE_RETRIES,
|
||||
);
|
||||
return {
|
||||
maxAgeMs: maxAgeMinutes * 60_000,
|
||||
perRunLimit,
|
||||
firstRunLookbackMs: firstRunLookbackMinutes * 60_000,
|
||||
maxFailureRetries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,11 +247,10 @@ async function runBlueBubblesCatchupInner(
|
||||
const procFn = deps.processMessageFn ?? processMessage;
|
||||
const accountId = target.account.accountId;
|
||||
|
||||
const { maxAgeMs, perRunLimit, firstRunLookbackMs, maxFailureRetries } = clampCatchupConfig(raw);
|
||||
const { maxAgeMs, perRunLimit, firstRunLookbackMs } = clampCatchupConfig(raw);
|
||||
const nowMs = now();
|
||||
const existing = await loadBlueBubblesCatchupCursor(accountId).catch(() => null);
|
||||
const cursorBefore = existing?.lastSeenMs ?? null;
|
||||
const prevRetries = existing?.failureRetries ?? {};
|
||||
|
||||
// Catchup runs once per gateway startup (called from monitor.ts after
|
||||
// webhook target registration). We deliberately do NOT short-circuit on
|
||||
@@ -416,15 +295,6 @@ async function runBlueBubblesCatchupInner(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure legacy→hashed dedupe file migration runs and the on-disk store
|
||||
// is warm before we replay. Without this, an upgrade from a version that
|
||||
// used the old `${safe}.json` naming to the current `${safe}__${hash}.json`
|
||||
// would start with an empty dedupe cache and re-dispatch every message in
|
||||
// the catchup window — producing duplicate replies.
|
||||
await warmupBlueBubblesInboundDedupe(accountId).catch((err) => {
|
||||
error?.(`[${accountId}] BlueBubbles catchup: dedupe warmup failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
const { resolved, messages } = await fetchFn(windowStartMs, perRunLimit, {
|
||||
baseUrl,
|
||||
password,
|
||||
@@ -436,9 +306,7 @@ async function runBlueBubblesCatchupInner(
|
||||
replayed: 0,
|
||||
skippedFromMe: 0,
|
||||
skippedPreCursor: 0,
|
||||
skippedGivenUp: 0,
|
||||
failed: 0,
|
||||
givenUp: 0,
|
||||
cursorBefore,
|
||||
cursorAfter: nowMs,
|
||||
windowStartMs,
|
||||
@@ -452,31 +320,18 @@ async function runBlueBubblesCatchupInner(
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Track the earliest timestamp where `processMessage` threw *and* the
|
||||
// failing message has not yet crossed the per-GUID retry ceiling, so we
|
||||
// never advance the cursor past a retryable failure. Normalize failures
|
||||
// (the record didn't yield a usable NormalizedWebhookMessage) are
|
||||
// treated as permanent skips and do NOT block cursor advance — those
|
||||
// payloads are unlikely to ever normalize on retry, and blocking on
|
||||
// them would wedge catchup forever. Given-up messages (count >= max)
|
||||
// also do NOT contribute here; see `skippedGivenUp` below.
|
||||
// Track the earliest timestamp where `processMessage` threw so we never
|
||||
// advance the cursor past a retryable failure. Normalize failures (the
|
||||
// record didn't yield a usable NormalizedWebhookMessage) are treated as
|
||||
// permanent skips and do NOT block cursor advance — those payloads are
|
||||
// unlikely to ever normalize on retry, and blocking on them would wedge
|
||||
// catchup forever.
|
||||
let earliestProcessFailureTs: number | null = null;
|
||||
// Track the latest fetched message timestamp regardless of fate, so a
|
||||
// truncated query (fetchedCount === perRunLimit) can advance the cursor
|
||||
// exactly to the page boundary. Without this, the unfetched tail past
|
||||
// the cap is permanently unreachable.
|
||||
let latestFetchedTs = windowStartMs;
|
||||
// Next-run retry map. Built from scratch each run so entries for GUIDs
|
||||
// that didn't appear in this fetch are dropped (the cursor has
|
||||
// advanced past them and they will never be queried again). Entries we
|
||||
// do carry forward encode two states via the stored count:
|
||||
// - `1 <= count < maxFailureRetries`: still-retrying, holds cursor.
|
||||
// - `count >= maxFailureRetries`: given-up, skipped on sight without
|
||||
// another `processMessage` attempt. Preserving the count is what
|
||||
// keeps the give-up state sticky across runs when an earlier
|
||||
// still-retrying failure is holding the cursor and the given-up
|
||||
// message keeps reappearing in the query window.
|
||||
const nextRetries: Record<string, number> = {};
|
||||
|
||||
for (const rec of messages) {
|
||||
// Defense in depth: the server-side `after:` filter should already
|
||||
@@ -498,30 +353,6 @@ async function runBlueBubblesCatchupInner(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip tapback/reaction/balloon events. These carry an
|
||||
// `associatedMessageGuid` pointing at the parent text message and
|
||||
// have a different `guid` of their own. The live webhook path handles
|
||||
// balloons via the debouncer, which coalesces them with their parent.
|
||||
// Without debouncing here, replaying a balloon would dispatch it as a
|
||||
// standalone message — producing a duplicate reply to the parent.
|
||||
//
|
||||
// Guard: only skip when `associatedMessageType` is set (tapbacks and
|
||||
// reactions — e.g., "like", 2000) OR `balloonBundleId` is set (URL
|
||||
// previews, stickers). iMessage threaded replies use a separate
|
||||
// `threadOriginatorGuid` field and do NOT set either of these, so
|
||||
// they pass through for correct catchup replay.
|
||||
const assocGuid =
|
||||
typeof rec.associatedMessageGuid === "string"
|
||||
? rec.associatedMessageGuid.trim()
|
||||
: typeof rec.associated_message_guid === "string"
|
||||
? rec.associated_message_guid.trim()
|
||||
: "";
|
||||
const assocType = rec.associatedMessageType ?? rec.associated_message_type;
|
||||
const balloonId = typeof rec.balloonBundleId === "string" ? rec.balloonBundleId.trim() : "";
|
||||
if (assocGuid && (assocType != null || balloonId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = normalizeWebhookMessage({ type: "new-message", data: rec });
|
||||
if (!normalized) {
|
||||
summary.failed++;
|
||||
@@ -532,62 +363,15 @@ async function runBlueBubblesCatchupInner(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prefer the normalized messageId (what the dedupe cache uses) so the
|
||||
// retry counter and downstream dedupe key agree on identity. Fall
|
||||
// back to the raw BB `guid` only when normalization didn't supply one.
|
||||
const retryKey = normalized.messageId ?? (typeof rec.guid === "string" ? rec.guid : "");
|
||||
|
||||
// Already-given-up GUIDs are skipped without another `processMessage`
|
||||
// attempt. This is what lets catchup make forward progress through an
|
||||
// earlier, still-retrying failure while not burning cycles re-running
|
||||
// a permanently broken message every sweep.
|
||||
const prevCount = retryKey ? (prevRetries[retryKey] ?? 0) : 0;
|
||||
if (retryKey && prevCount >= maxFailureRetries) {
|
||||
summary.skippedGivenUp++;
|
||||
// Preserve the count so give-up stickiness survives this run.
|
||||
nextRetries[retryKey] = prevCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await procFn(normalized, target);
|
||||
summary.replayed++;
|
||||
// Success clears any accumulated retries for this GUID. Since we
|
||||
// build `nextRetries` from scratch rather than mutating
|
||||
// `prevRetries`, simply NOT copying the entry is the clear. (We
|
||||
// still need this branch so readers understand the lifecycle.)
|
||||
} catch (err) {
|
||||
summary.failed++;
|
||||
const nextCount = prevCount + 1;
|
||||
if (retryKey && nextCount >= maxFailureRetries) {
|
||||
// Crossing the ceiling this run: log WARN once and record the
|
||||
// give-up in the persisted map. Don't contribute to
|
||||
// `earliestProcessFailureTs` — we're intentionally letting the
|
||||
// cursor advance past this GUID on the next sweep.
|
||||
summary.givenUp++;
|
||||
nextRetries[retryKey] = nextCount;
|
||||
error?.(
|
||||
`[${accountId}] BlueBubbles catchup: giving up on guid=${retryKey} ` +
|
||||
`after ${nextCount} consecutive failures; future sweeps will skip ` +
|
||||
`this message. timestamp=${ts}: ${String(err)}`,
|
||||
);
|
||||
} else {
|
||||
// Still retrying: count this failure and hold the cursor so the
|
||||
// next sweep retries the same window. (retryKey may be empty in
|
||||
// the unusual case where neither normalizer nor raw payload
|
||||
// carried a GUID — in that case we hold the cursor but cannot
|
||||
// increment a counter, matching pre-retry-cap behavior.)
|
||||
if (retryKey) {
|
||||
nextRetries[retryKey] = nextCount;
|
||||
}
|
||||
if (ts > 0 && (earliestProcessFailureTs === null || ts < earliestProcessFailureTs)) {
|
||||
earliestProcessFailureTs = ts;
|
||||
}
|
||||
error?.(
|
||||
`[${accountId}] BlueBubbles catchup: processMessage failed (retry ` +
|
||||
`${nextCount}/${maxFailureRetries}): ${String(err)}`,
|
||||
);
|
||||
if (ts > 0 && (earliestProcessFailureTs === null || ts < earliestProcessFailureTs)) {
|
||||
earliestProcessFailureTs = ts;
|
||||
}
|
||||
error?.(`[${accountId}] BlueBubbles catchup: processMessage failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,17 +381,10 @@ async function runBlueBubblesCatchupInner(
|
||||
// this sweep finished (avoiding stuck rescans of a message with
|
||||
// `dateCreated > nowMs` from minor clock skew between BB host and
|
||||
// gateway host).
|
||||
// - On retryable failure (any still-retrying `processMessage` throw,
|
||||
// where the GUID has NOT crossed `maxFailureRetries`): hold the
|
||||
// cursor just before the earliest still-retrying failed timestamp so
|
||||
// the next run retries from there. The inbound-dedupe cache from
|
||||
// #66230 keeps successfully replayed messages from being re-processed.
|
||||
// - On give-up (failures that crossed `maxFailureRetries`): the GUID
|
||||
// is recorded in the persisted retry map with `count >= max` and
|
||||
// skipped on sight in subsequent runs (without another processMessage
|
||||
// attempt). Give-up GUIDs intentionally do NOT hold the cursor, so
|
||||
// the cursor can advance past them naturally — this is what unwedges
|
||||
// catchup from a permanently malformed message (issue #66870).
|
||||
// - On retryable failure (any `processMessage` throw): hold the cursor
|
||||
// just before the earliest failed timestamp so the next run retries
|
||||
// from there. The inbound-dedupe cache from #66230 keeps successfully
|
||||
// replayed messages from being re-processed.
|
||||
// - On truncation (fetched === perRunLimit): advance only to the latest
|
||||
// fetched timestamp so the next run picks up from the page boundary.
|
||||
// Otherwise the unfetched tail past the cap (which can be substantial
|
||||
@@ -623,18 +400,14 @@ async function runBlueBubblesCatchupInner(
|
||||
nextCursorMs = Math.min(Math.max(latestFetchedTs, cursorBefore ?? windowStartMs), nowMs);
|
||||
}
|
||||
summary.cursorAfter = nextCursorMs;
|
||||
// Cap the retry map before writing — defense in depth against a storm
|
||||
// of unique failing GUIDs ballooning the cursor file.
|
||||
const retriesToPersist = capFailureRetriesMap(nextRetries, MAX_FAILURE_RETRY_MAP_SIZE);
|
||||
await saveBlueBubblesCatchupCursor(accountId, nextCursorMs, retriesToPersist).catch((err) => {
|
||||
await saveBlueBubblesCatchupCursor(accountId, nextCursorMs).catch((err) => {
|
||||
error?.(`[${accountId}] BlueBubbles catchup: cursor save failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
log?.(
|
||||
`[${accountId}] BlueBubbles catchup: replayed=${summary.replayed} ` +
|
||||
`skipped_fromMe=${summary.skippedFromMe} skipped_preCursor=${summary.skippedPreCursor} ` +
|
||||
`skipped_givenUp=${summary.skippedGivenUp} failed=${summary.failed} ` +
|
||||
`given_up=${summary.givenUp} fetched=${summary.fetchedCount} ` +
|
||||
`failed=${summary.failed} fetched=${summary.fetchedCount} ` +
|
||||
`window_ms=${nowMs - windowStartMs}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -50,14 +50,6 @@ const bluebubblesCatchupSchema = z
|
||||
perRunLimit: z.number().int().positive().optional(),
|
||||
/** First-run lookback used when no cursor has been persisted yet. Clamped to [1, 720]. */
|
||||
firstRunLookbackMinutes: z.number().int().positive().optional(),
|
||||
/**
|
||||
* Consecutive-failure ceiling per message GUID. After this many failed
|
||||
* processMessage attempts against the same GUID, catchup logs a WARN
|
||||
* and skips the message on subsequent sweeps (letting the cursor
|
||||
* advance past a permanently malformed payload). Defaults to 10.
|
||||
* Clamped to [1, 1000].
|
||||
*/
|
||||
maxFailureRetries: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { type ClaimableDedupe, createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
@@ -34,11 +33,6 @@ function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return resolveStateDir(env);
|
||||
}
|
||||
|
||||
function resolveLegacyNamespaceFilePath(namespace: string): string {
|
||||
const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_") || "global";
|
||||
return path.join(resolveStateDirFromEnv(), "bluebubbles", "inbound-dedupe", `${safe}.json`);
|
||||
}
|
||||
|
||||
function resolveNamespaceFilePath(namespace: string): string {
|
||||
// Keep a readable prefix for operator debugging, but suffix with a short
|
||||
// hash of the raw namespace so account IDs that only differ by
|
||||
@@ -46,42 +40,12 @@ function resolveNamespaceFilePath(namespace: string): string {
|
||||
// onto the same file.
|
||||
const safePrefix = namespace.replace(/[^a-zA-Z0-9_-]/g, "_") || "ns";
|
||||
const hash = createHash("sha256").update(namespace, "utf8").digest("hex").slice(0, 12);
|
||||
const dir = path.join(resolveStateDirFromEnv(), "bluebubbles", "inbound-dedupe");
|
||||
const newPath = path.join(dir, `${safePrefix}__${hash}.json`);
|
||||
|
||||
// One-time migration: earlier beta shipped `${safe}.json` (no hash).
|
||||
// Rename so the upgrade preserves existing dedupe entries instead of
|
||||
// starting from an empty file and replaying already-handled messages.
|
||||
migrateLegacyDedupeFile(namespace, newPath);
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
const migratedNamespaces = new Set<string>();
|
||||
|
||||
function migrateLegacyDedupeFile(namespace: string, newPath: string): void {
|
||||
if (migratedNamespaces.has(namespace)) {
|
||||
return;
|
||||
}
|
||||
migratedNamespaces.add(namespace);
|
||||
try {
|
||||
const legacyPath = resolveLegacyNamespaceFilePath(namespace);
|
||||
if (legacyPath === newPath) {
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(legacyPath)) {
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(newPath)) {
|
||||
fs.renameSync(legacyPath, newPath);
|
||||
} else {
|
||||
// Both exist: new file is authoritative; remove the stale legacy.
|
||||
fs.unlinkSync(legacyPath);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort migration; a missed rename is strictly less harmful
|
||||
// than crashing the module load path.
|
||||
}
|
||||
return path.join(
|
||||
resolveStateDirFromEnv(),
|
||||
"bluebubbles",
|
||||
"inbound-dedupe",
|
||||
`${safePrefix}__${hash}.json`,
|
||||
);
|
||||
}
|
||||
|
||||
function buildPersistentImpl(): ClaimableDedupe {
|
||||
@@ -198,18 +162,6 @@ export async function claimBlueBubblesInboundMessage(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the legacy→hashed dedupe file migration runs and the on-disk
|
||||
* store is warmed into memory for the given account. Call before any
|
||||
* catchup replay so already-handled GUIDs are recognized even when the
|
||||
* file-naming convention changed between versions.
|
||||
*/
|
||||
export async function warmupBlueBubblesInboundDedupe(accountId: string): Promise<void> {
|
||||
// Trigger the migration side-effect inside resolveNamespaceFilePath.
|
||||
resolveNamespaceFilePath(accountId);
|
||||
await impl.warmup(accountId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset inbound dedupe state between tests. Installs an in-memory-only
|
||||
* implementation so tests do not hit disk, avoiding file-lock timing issues
|
||||
|
||||
@@ -3,6 +3,6 @@ export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
|
||||
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
|
||||
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
|
||||
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 40_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 8_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000;
|
||||
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;
|
||||
|
||||
@@ -5,14 +5,9 @@ import path from "node:path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { ChannelType, type Client, ReadyListener } from "@buape/carbon";
|
||||
import type { VoicePlugin } from "@buape/carbon/voice";
|
||||
import {
|
||||
agentCommandFromIngress,
|
||||
getTtsProvider,
|
||||
resolveAgentDir,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
type ResolvedTtsConfig,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveTtsConfig, type ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordAccountConfig, TtsConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
@@ -814,7 +809,6 @@ export class DiscordVoiceManager {
|
||||
const directive = parseTtsDirectives(replyText, ttsConfig.modelOverrides, {
|
||||
cfg: ttsCfg,
|
||||
providerConfigs: ttsConfig.providerConfigs,
|
||||
preferredProviderId: getTtsProvider(ttsConfig, resolveTtsPrefsPath(ttsConfig)),
|
||||
});
|
||||
const rawSpeakText = directive.overrides.ttsText ?? directive.cleanedText.trim();
|
||||
const speakText = sanitizeVoiceReplyTextForSpeech(rawSpeakText, speaker.label);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"id": "elevenlabs",
|
||||
"enabledByDefault": true,
|
||||
"contracts": {
|
||||
"speechProviders": ["elevenlabs"]
|
||||
},
|
||||
|
||||
@@ -5,19 +5,18 @@ import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
|
||||
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
|
||||
import { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
import { registerGoogleProvider } from "./provider-registration.js";
|
||||
import { buildGoogleSpeechProvider } from "./speech-provider.js";
|
||||
import { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
|
||||
import { buildGoogleVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
let googleImageGenerationProviderPromise: Promise<ImageGenerationProvider> | null = null;
|
||||
let googleMediaUnderstandingProviderPromise: Promise<MediaUnderstandingProvider> | null = null;
|
||||
|
||||
type GoogleMediaUnderstandingProvider = Required<
|
||||
Pick<
|
||||
MediaUnderstandingProvider,
|
||||
"describeImage" | "describeImages" | "transcribeAudio" | "describeVideo"
|
||||
>
|
||||
>;
|
||||
type GoogleMediaUnderstandingProvider = MediaUnderstandingProvider & {
|
||||
describeImage: NonNullable<MediaUnderstandingProvider["describeImage"]>;
|
||||
describeImages: NonNullable<MediaUnderstandingProvider["describeImages"]>;
|
||||
transcribeAudio: NonNullable<MediaUnderstandingProvider["transcribeAudio"]>;
|
||||
describeVideo: NonNullable<MediaUnderstandingProvider["describeVideo"]>;
|
||||
};
|
||||
|
||||
async function loadGoogleImageGenerationProvider(): Promise<ImageGenerationProvider> {
|
||||
if (!googleImageGenerationProviderPromise) {
|
||||
@@ -114,7 +113,6 @@ export default definePluginEntry({
|
||||
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
|
||||
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
|
||||
api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider());
|
||||
api.registerSpeechProvider(buildGoogleSpeechProvider());
|
||||
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());
|
||||
api.registerWebSearchProvider(createGeminiWebSearchProvider());
|
||||
},
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"mediaUnderstandingProviders": ["google"],
|
||||
"imageGenerationProviders": ["google"],
|
||||
"musicGenerationProviders": ["google"],
|
||||
"speechProviders": ["google"],
|
||||
"videoGenerationProviders": ["google"],
|
||||
"webSearchProviders": ["gemini"]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ import { describePluginRegistrationContract } from "../../test/helpers/plugins/p
|
||||
|
||||
describePluginRegistrationContract({
|
||||
...pluginRegistrationContractCases.google,
|
||||
speechProviderIds: ["google"],
|
||||
videoGenerationProviderIds: ["google"],
|
||||
webSearchProviderIds: ["gemini"],
|
||||
requireDescribeImages: true,
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleSpeechProvider, __testing } from "./speech-provider.js";
|
||||
|
||||
function installGoogleTtsFetchMock(pcm = Buffer.from([1, 0, 2, 0])) {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: "audio/L16;codec=pcm;rate=24000",
|
||||
data: pcm.toString("base64"),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("Google speech provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("synthesizes Gemini PCM as WAV and preserves audio tags in the request text", async () => {
|
||||
const fetchMock = installGoogleTtsFetchMock();
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "[whispers] The door is open.",
|
||||
cfg: {},
|
||||
providerConfig: {
|
||||
apiKey: "google-test-key",
|
||||
model: "google/gemini-3.1-flash-tts",
|
||||
voiceName: "Puck",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 12_345,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-tts-preview:generateContent",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: "[whispers] The door is open." }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: {
|
||||
voiceConfig: {
|
||||
prebuiltVoiceConfig: {
|
||||
voiceName: "Puck",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(new Headers(init.headers).get("x-goog-api-key")).toBe("google-test-key");
|
||||
expect(result.outputFormat).toBe("wav");
|
||||
expect(result.fileExtension).toBe(".wav");
|
||||
expect(result.voiceCompatible).toBe(false);
|
||||
expect(result.audioBuffer.subarray(0, 4).toString("ascii")).toBe("RIFF");
|
||||
expect(result.audioBuffer.subarray(8, 12).toString("ascii")).toBe("WAVE");
|
||||
expect(result.audioBuffer.readUInt32LE(24)).toBe(__testing.GOOGLE_TTS_SAMPLE_RATE);
|
||||
expect(result.audioBuffer.subarray(44)).toEqual(Buffer.from([1, 0, 2, 0]));
|
||||
});
|
||||
|
||||
it("falls back to GEMINI_API_KEY and configured Google API base URL", async () => {
|
||||
vi.stubEnv("GEMINI_API_KEY", "env-google-key");
|
||||
const fetchMock = installGoogleTtsFetchMock();
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
expect(provider.isConfigured({ providerConfig: {}, timeoutMs: 1 })).toBe(true);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Read this plainly.",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
providerConfig: {},
|
||||
target: "voice-note",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-tts-preview:generateContent",
|
||||
expect.any(Object),
|
||||
);
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(new Headers(init.headers).get("x-goog-api-key")).toBe("env-google-key");
|
||||
});
|
||||
|
||||
it("can reuse a configured Google model-provider API key without auth profiles", async () => {
|
||||
const fetchMock = installGoogleTtsFetchMock();
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
apiKey: "model-provider-google-key",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(provider.isConfigured({ cfg, providerConfig: {}, timeoutMs: 1 })).toBe(true);
|
||||
|
||||
await provider.synthesize({
|
||||
text: "Use the configured model provider key.",
|
||||
cfg,
|
||||
providerConfig: {},
|
||||
target: "audio-file",
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(new Headers(init.headers).get("x-goog-api-key")).toBe("model-provider-google-key");
|
||||
});
|
||||
|
||||
it("returns Gemini PCM directly for telephony synthesis", async () => {
|
||||
const pcm = Buffer.from([3, 0, 4, 0]);
|
||||
installGoogleTtsFetchMock(pcm);
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
const result = await provider.synthesizeTelephony?.({
|
||||
text: "Phone call audio.",
|
||||
cfg: {},
|
||||
providerConfig: {
|
||||
apiKey: "google-test-key",
|
||||
voice: "Kore",
|
||||
},
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
audioBuffer: pcm,
|
||||
outputFormat: "pcm",
|
||||
sampleRate: 24_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves provider config and directive overrides", () => {
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
expect(
|
||||
provider.resolveConfig?.({
|
||||
cfg: {},
|
||||
rawConfig: {
|
||||
providers: {
|
||||
google: {
|
||||
apiKey: "configured-key",
|
||||
model: "google/gemini-3.1-flash-tts-preview",
|
||||
voice: "Leda",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "configured-key",
|
||||
baseUrl: undefined,
|
||||
model: "gemini-3.1-flash-tts-preview",
|
||||
voiceName: "Leda",
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.parseDirectiveToken?.({
|
||||
key: "google_voice",
|
||||
value: "Aoede",
|
||||
policy: {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
overrides: {
|
||||
voiceName: "Aoede",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.parseDirectiveToken?.({
|
||||
key: "google_model",
|
||||
value: "gemini-3.1-flash-tts-preview",
|
||||
policy: {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
overrides: {
|
||||
model: "gemini-3.1-flash-tts-preview",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists Gemini prebuilt TTS voices", async () => {
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
await expect(provider.listVoices?.({ providerConfig: {} })).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: "Kore", name: "Kore" },
|
||||
{ id: "Puck", name: "Puck" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,391 +0,0 @@
|
||||
import { assertOkOrThrowHttpError, postJsonRequest } from "openclaw/plugin-sdk/provider-http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderOverrides,
|
||||
SpeechProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveGoogleGenerativeAiHttpRequestConfig } from "./api.js";
|
||||
|
||||
const DEFAULT_GOOGLE_TTS_MODEL = "gemini-3.1-flash-tts-preview";
|
||||
const DEFAULT_GOOGLE_TTS_VOICE = "Kore";
|
||||
const GOOGLE_TTS_SAMPLE_RATE = 24_000;
|
||||
const GOOGLE_TTS_CHANNELS = 1;
|
||||
const GOOGLE_TTS_BITS_PER_SAMPLE = 16;
|
||||
|
||||
const GOOGLE_TTS_VOICES = [
|
||||
"Zephyr",
|
||||
"Puck",
|
||||
"Charon",
|
||||
"Kore",
|
||||
"Fenrir",
|
||||
"Leda",
|
||||
"Orus",
|
||||
"Aoede",
|
||||
"Callirrhoe",
|
||||
"Autonoe",
|
||||
"Enceladus",
|
||||
"Iapetus",
|
||||
"Umbriel",
|
||||
"Algieba",
|
||||
"Despina",
|
||||
"Erinome",
|
||||
"Algenib",
|
||||
"Rasalgethi",
|
||||
"Laomedeia",
|
||||
"Achernar",
|
||||
"Alnilam",
|
||||
"Schedar",
|
||||
"Gacrux",
|
||||
"Pulcherrima",
|
||||
"Achird",
|
||||
"Zubenelgenubi",
|
||||
"Vindemiatrix",
|
||||
"Sadachbia",
|
||||
"Sadaltager",
|
||||
"Sulafat",
|
||||
] as const;
|
||||
|
||||
type GoogleTtsProviderConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
voiceName: string;
|
||||
};
|
||||
|
||||
type GoogleTtsProviderOverrides = {
|
||||
model?: string;
|
||||
voiceName?: string;
|
||||
};
|
||||
|
||||
type Maybe<T> = T | undefined;
|
||||
|
||||
type GoogleInlineDataPart = {
|
||||
mimeType?: string;
|
||||
mime_type?: string;
|
||||
data?: string;
|
||||
};
|
||||
|
||||
type GoogleGenerateSpeechResponse = {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
inlineData?: GoogleInlineDataPart;
|
||||
inline_data?: GoogleInlineDataPart;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
function normalizeGoogleTtsModel(model: unknown): string {
|
||||
const trimmed = normalizeOptionalString(model);
|
||||
if (!trimmed) {
|
||||
return DEFAULT_GOOGLE_TTS_MODEL;
|
||||
}
|
||||
const withoutProvider = trimmed.startsWith("google/") ? trimmed.slice("google/".length) : trimmed;
|
||||
return withoutProvider === "gemini-3.1-flash-tts" ? DEFAULT_GOOGLE_TTS_MODEL : withoutProvider;
|
||||
}
|
||||
|
||||
function normalizeGoogleTtsVoiceName(voiceName: unknown): string {
|
||||
return normalizeOptionalString(voiceName) ?? DEFAULT_GOOGLE_TTS_VOICE;
|
||||
}
|
||||
|
||||
function resolveGoogleTtsEnvApiKey(): string | undefined {
|
||||
return (
|
||||
normalizeOptionalString(process.env.GEMINI_API_KEY) ??
|
||||
normalizeOptionalString(process.env.GOOGLE_API_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGoogleTtsModelProviderApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
return normalizeResolvedSecretInputString({
|
||||
value: cfg?.models?.providers?.google?.apiKey,
|
||||
path: "models.providers.google.apiKey",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveGoogleTtsApiKey(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
providerConfig: SpeechProviderConfig;
|
||||
}): string | undefined {
|
||||
return (
|
||||
readGoogleTtsProviderConfig(params.providerConfig).apiKey ??
|
||||
resolveGoogleTtsModelProviderApiKey(params.cfg) ??
|
||||
resolveGoogleTtsEnvApiKey()
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGoogleTtsBaseUrl(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
providerConfig: GoogleTtsProviderConfig;
|
||||
}): string | undefined {
|
||||
return (
|
||||
params.providerConfig.baseUrl ?? trimToUndefined(params.cfg?.models?.providers?.google?.baseUrl)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGoogleTtsConfigRecord(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
return asObject(providers?.google) ?? asObject(rawConfig.google);
|
||||
}
|
||||
|
||||
function normalizeGoogleTtsProviderConfig(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): GoogleTtsProviderConfig {
|
||||
const raw = resolveGoogleTtsConfigRecord(rawConfig);
|
||||
return {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: raw?.apiKey,
|
||||
path: "messages.tts.providers.google.apiKey",
|
||||
}),
|
||||
baseUrl: trimToUndefined(raw?.baseUrl),
|
||||
model: normalizeGoogleTtsModel(raw?.model),
|
||||
voiceName: normalizeGoogleTtsVoiceName(raw?.voiceName ?? raw?.voice),
|
||||
};
|
||||
}
|
||||
|
||||
function readGoogleTtsProviderConfig(config: SpeechProviderConfig): GoogleTtsProviderConfig {
|
||||
const normalized = normalizeGoogleTtsProviderConfig({});
|
||||
return {
|
||||
apiKey: trimToUndefined(config.apiKey) ?? normalized.apiKey,
|
||||
baseUrl: trimToUndefined(config.baseUrl) ?? normalized.baseUrl,
|
||||
model: normalizeGoogleTtsModel(config.model ?? normalized.model),
|
||||
voiceName: normalizeGoogleTtsVoiceName(
|
||||
config.voiceName ?? config.voice ?? normalized.voiceName,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function readGoogleTtsOverrides(
|
||||
overrides: Maybe<SpeechProviderOverrides>,
|
||||
): GoogleTtsProviderOverrides {
|
||||
if (!overrides) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
model: normalizeOptionalString(overrides.model),
|
||||
voiceName: normalizeOptionalString(overrides.voiceName ?? overrides.voice),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
|
||||
handled: boolean;
|
||||
overrides?: SpeechProviderOverrides;
|
||||
warnings?: string[];
|
||||
} {
|
||||
switch (ctx.key) {
|
||||
case "voicename":
|
||||
case "voice_name":
|
||||
case "google_voice":
|
||||
case "googlevoice":
|
||||
if (!ctx.policy.allowVoice) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { voiceName: ctx.value } };
|
||||
case "google_model":
|
||||
case "googlemodel":
|
||||
if (!ctx.policy.allowModelId) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { model: ctx.value } };
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
function extractGoogleSpeechPcm(payload: GoogleGenerateSpeechResponse): Buffer {
|
||||
for (const candidate of payload.candidates ?? []) {
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
const inline = part.inlineData ?? part.inline_data;
|
||||
const data = normalizeOptionalString(inline?.data);
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
return Buffer.from(data, "base64");
|
||||
}
|
||||
}
|
||||
throw new Error("Google TTS response missing audio data");
|
||||
}
|
||||
|
||||
function wrapPcm16MonoToWav(pcm: Buffer, sampleRate = GOOGLE_TTS_SAMPLE_RATE): Buffer {
|
||||
const byteRate = sampleRate * GOOGLE_TTS_CHANNELS * (GOOGLE_TTS_BITS_PER_SAMPLE / 8);
|
||||
const blockAlign = GOOGLE_TTS_CHANNELS * (GOOGLE_TTS_BITS_PER_SAMPLE / 8);
|
||||
const header = Buffer.alloc(44);
|
||||
|
||||
header.write("RIFF", 0, "ascii");
|
||||
header.writeUInt32LE(36 + pcm.length, 4);
|
||||
header.write("WAVE", 8, "ascii");
|
||||
header.write("fmt ", 12, "ascii");
|
||||
header.writeUInt32LE(16, 16);
|
||||
header.writeUInt16LE(1, 20);
|
||||
header.writeUInt16LE(GOOGLE_TTS_CHANNELS, 22);
|
||||
header.writeUInt32LE(sampleRate, 24);
|
||||
header.writeUInt32LE(byteRate, 28);
|
||||
header.writeUInt16LE(blockAlign, 32);
|
||||
header.writeUInt16LE(GOOGLE_TTS_BITS_PER_SAMPLE, 34);
|
||||
header.write("data", 36, "ascii");
|
||||
header.writeUInt32LE(pcm.length, 40);
|
||||
|
||||
return Buffer.concat([header, pcm]);
|
||||
}
|
||||
|
||||
async function synthesizeGoogleTtsPcm(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
voiceName: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<Buffer> {
|
||||
const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy } =
|
||||
resolveGoogleGenerativeAiHttpRequestConfig({
|
||||
apiKey: params.apiKey,
|
||||
baseUrl: params.baseUrl,
|
||||
capability: "audio",
|
||||
transport: "http",
|
||||
});
|
||||
|
||||
const { response: res, release } = await postJsonRequest({
|
||||
url: `${baseUrl}/models/${params.model}:generateContent`,
|
||||
headers,
|
||||
body: {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: params.text }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
responseModalities: ["AUDIO"],
|
||||
speechConfig: {
|
||||
voiceConfig: {
|
||||
prebuiltVoiceConfig: {
|
||||
voiceName: params.voiceName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
fetchFn: fetch,
|
||||
pinDns: false,
|
||||
allowPrivateNetwork,
|
||||
dispatcherPolicy,
|
||||
});
|
||||
|
||||
try {
|
||||
await assertOkOrThrowHttpError(res, "Google TTS failed");
|
||||
return extractGoogleSpeechPcm((await res.json()) as GoogleGenerateSpeechResponse);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "google",
|
||||
label: "Google",
|
||||
autoSelectOrder: 50,
|
||||
models: [DEFAULT_GOOGLE_TTS_MODEL],
|
||||
voices: GOOGLE_TTS_VOICES,
|
||||
resolveConfig: ({ rawConfig }) => normalizeGoogleTtsProviderConfig(rawConfig),
|
||||
parseDirectiveToken,
|
||||
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
||||
const base = normalizeGoogleTtsProviderConfig(baseTtsConfig);
|
||||
return {
|
||||
...base,
|
||||
...(talkProviderConfig.apiKey === undefined
|
||||
? {}
|
||||
: {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: talkProviderConfig.apiKey,
|
||||
path: "talk.providers.google.apiKey",
|
||||
}),
|
||||
}),
|
||||
...(trimToUndefined(talkProviderConfig.baseUrl) == null
|
||||
? {}
|
||||
: { baseUrl: trimToUndefined(talkProviderConfig.baseUrl) }),
|
||||
...(trimToUndefined(talkProviderConfig.modelId) == null
|
||||
? {}
|
||||
: { model: normalizeGoogleTtsModel(talkProviderConfig.modelId) }),
|
||||
...(trimToUndefined(talkProviderConfig.voiceId) == null
|
||||
? {}
|
||||
: { voiceName: normalizeGoogleTtsVoiceName(talkProviderConfig.voiceId) }),
|
||||
};
|
||||
},
|
||||
resolveTalkOverrides: ({ params }) => ({
|
||||
...(trimToUndefined(params.voiceId) == null
|
||||
? {}
|
||||
: { voiceName: normalizeGoogleTtsVoiceName(params.voiceId) }),
|
||||
...(trimToUndefined(params.modelId) == null
|
||||
? {}
|
||||
: { model: normalizeGoogleTtsModel(params.modelId) }),
|
||||
}),
|
||||
listVoices: async () => GOOGLE_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
|
||||
isConfigured: ({ cfg, providerConfig }) =>
|
||||
Boolean(resolveGoogleTtsApiKey({ cfg, providerConfig })),
|
||||
synthesize: async (req) => {
|
||||
const config = readGoogleTtsProviderConfig(req.providerConfig);
|
||||
const overrides = readGoogleTtsOverrides(req.providerOverrides);
|
||||
const apiKey = resolveGoogleTtsApiKey({
|
||||
cfg: req.cfg,
|
||||
providerConfig: req.providerConfig,
|
||||
});
|
||||
if (!apiKey) {
|
||||
throw new Error("Google API key missing");
|
||||
}
|
||||
const pcm = await synthesizeGoogleTtsPcm({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }),
|
||||
model: normalizeGoogleTtsModel(overrides.model ?? config.model),
|
||||
voiceName: normalizeGoogleTtsVoiceName(overrides.voiceName ?? config.voiceName),
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
return {
|
||||
audioBuffer: wrapPcm16MonoToWav(pcm),
|
||||
outputFormat: "wav",
|
||||
fileExtension: ".wav",
|
||||
voiceCompatible: false,
|
||||
};
|
||||
},
|
||||
synthesizeTelephony: async (req) => {
|
||||
const config = readGoogleTtsProviderConfig(req.providerConfig);
|
||||
const apiKey = resolveGoogleTtsApiKey({
|
||||
cfg: req.cfg,
|
||||
providerConfig: req.providerConfig,
|
||||
});
|
||||
if (!apiKey) {
|
||||
throw new Error("Google API key missing");
|
||||
}
|
||||
const pcm = await synthesizeGoogleTtsPcm({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }),
|
||||
model: config.model,
|
||||
voiceName: config.voiceName,
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
return {
|
||||
audioBuffer: pcm,
|
||||
outputFormat: "pcm",
|
||||
sampleRate: GOOGLE_TTS_SAMPLE_RATE,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
DEFAULT_GOOGLE_TTS_MODEL,
|
||||
DEFAULT_GOOGLE_TTS_VOICE,
|
||||
GOOGLE_TTS_SAMPLE_RATE,
|
||||
normalizeGoogleTtsModel,
|
||||
wrapPcm16MonoToWav,
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
export { buildGoogleGeminiCliBackend } from "./cli-backend.js";
|
||||
export { buildGoogleImageGenerationProvider } from "./image-generation-provider.js";
|
||||
export { buildGoogleMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
export { buildGoogleSpeechProvider } from "./speech-provider.js";
|
||||
export { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
export { buildGoogleVideoGenerationProvider } from "./video-generation-provider.js";
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetLmstudioPreloadCooldownForTest,
|
||||
wrapLmstudioInferencePreload,
|
||||
} from "./stream.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { wrapLmstudioInferencePreload } from "./stream.js";
|
||||
|
||||
const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn());
|
||||
const resolveLmstudioProviderHeadersMock = vi.hoisted(() =>
|
||||
@@ -54,17 +51,12 @@ function buildDoneStreamFn(): StreamFn {
|
||||
}
|
||||
|
||||
describe("lmstudio stream wrapper", () => {
|
||||
beforeEach(() => {
|
||||
__resetLmstudioPreloadCooldownForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ensureLmstudioModelLoadedMock.mockReset();
|
||||
resolveLmstudioProviderHeadersMock.mockReset();
|
||||
resolveLmstudioRuntimeApiKeyMock.mockReset();
|
||||
resolveLmstudioProviderHeadersMock.mockResolvedValue(undefined);
|
||||
resolveLmstudioRuntimeApiKeyMock.mockResolvedValue(undefined);
|
||||
__resetLmstudioPreloadCooldownForTest();
|
||||
});
|
||||
|
||||
it("preloads LM Studio model before inference using model context window", async () => {
|
||||
@@ -251,113 +243,6 @@ describe("lmstudio stream wrapper", () => {
|
||||
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips preload on the second attempt while the failure backoff is active", async () => {
|
||||
ensureLmstudioModelLoadedMock.mockRejectedValue(new Error("out of memory"));
|
||||
const baseStream = buildDoneStreamFn();
|
||||
const wrapped = wrapLmstudioInferencePreload({
|
||||
provider: "lmstudio",
|
||||
modelId: "qwen3-8b-instruct",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://localhost:1234",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
streamFn: baseStream,
|
||||
} as never);
|
||||
|
||||
const firstEvents = await collectEvents(
|
||||
wrapped(
|
||||
{
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
id: "qwen3-8b-instruct",
|
||||
} as never,
|
||||
{ messages: [] } as never,
|
||||
undefined as never,
|
||||
),
|
||||
);
|
||||
expect(firstEvents).toEqual([expect.objectContaining({ type: "done" })]);
|
||||
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const secondEvents = await collectEvents(
|
||||
wrapped(
|
||||
{
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
id: "qwen3-8b-instruct",
|
||||
} as never,
|
||||
{ messages: [] } as never,
|
||||
undefined as never,
|
||||
),
|
||||
);
|
||||
expect(secondEvents).toEqual([expect.objectContaining({ type: "done" })]);
|
||||
// The second call must NOT retry preload because cooldown is active, but
|
||||
// the underlying stream must still run so the user gets a response.
|
||||
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1);
|
||||
expect(baseStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries preload once the cooldown expires", async () => {
|
||||
ensureLmstudioModelLoadedMock.mockRejectedValueOnce(new Error("out of memory"));
|
||||
ensureLmstudioModelLoadedMock.mockResolvedValueOnce(undefined);
|
||||
const baseStream = buildDoneStreamFn();
|
||||
const wrapped = wrapLmstudioInferencePreload({
|
||||
provider: "lmstudio",
|
||||
modelId: "qwen3-8b-instruct",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://localhost:1234",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
streamFn: baseStream,
|
||||
} as never);
|
||||
|
||||
// Freeze Date.now at a known base so we can jump past the first backoff
|
||||
// window (5s by default) between the two preload attempts.
|
||||
const baseTime = 1_000_000;
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
nowSpy.mockReturnValue(baseTime);
|
||||
await collectEvents(
|
||||
wrapped(
|
||||
{
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
id: "qwen3-8b-instruct",
|
||||
} as never,
|
||||
{ messages: [] } as never,
|
||||
undefined as never,
|
||||
),
|
||||
);
|
||||
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Move the clock past the initial 5s cooldown window so the next call is
|
||||
// allowed to retry preload.
|
||||
nowSpy.mockReturnValue(baseTime + 6_000);
|
||||
await collectEvents(
|
||||
wrapped(
|
||||
{
|
||||
provider: "lmstudio",
|
||||
api: "openai-completions",
|
||||
id: "qwen3-8b-instruct",
|
||||
} as never,
|
||||
{ messages: [] } as never,
|
||||
undefined as never,
|
||||
),
|
||||
);
|
||||
expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(2);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("forces supportsUsageInStreaming compat before calling the underlying stream", async () => {
|
||||
const baseStream = buildDoneStreamFn();
|
||||
const wrapped = wrapLmstudioInferencePreload({
|
||||
|
||||
@@ -15,68 +15,6 @@ type StreamModel = Parameters<StreamFn>[0];
|
||||
|
||||
const preloadInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Cooldown state for the LM Studio preload endpoint.
|
||||
*
|
||||
* Without this, every chat request would retry preload ~every 2s even when
|
||||
* LM Studio has rejected the load (for example the memory guardrail will keep
|
||||
* rejecting until the user adjusts the setting or frees RAM). That produced
|
||||
* hundreds of `LM Studio inference preload failed` WARN lines per hour without
|
||||
* actually helping the user. The cooldown applies an exponential backoff per
|
||||
* preloadKey and, while the cooldown is active, the wrapper skips the preload
|
||||
* step entirely and proceeds directly to streaming — the model is often
|
||||
* already loaded from the user's LM Studio UI, so inference can succeed even
|
||||
* when preload keeps being rejected.
|
||||
*/
|
||||
type PreloadCooldownEntry = {
|
||||
untilMs: number;
|
||||
consecutiveFailures: number;
|
||||
};
|
||||
|
||||
const preloadCooldown = new Map<string, PreloadCooldownEntry>();
|
||||
|
||||
const PRELOAD_BACKOFF_BASE_MS = 5_000;
|
||||
const PRELOAD_BACKOFF_MAX_MS = 300_000;
|
||||
|
||||
function computePreloadBackoffMs(consecutiveFailures: number): number {
|
||||
const exponent = Math.max(0, consecutiveFailures - 1);
|
||||
const raw = PRELOAD_BACKOFF_BASE_MS * 2 ** exponent;
|
||||
return Math.min(PRELOAD_BACKOFF_MAX_MS, raw);
|
||||
}
|
||||
|
||||
function recordPreloadSuccess(preloadKey: string): void {
|
||||
preloadCooldown.delete(preloadKey);
|
||||
}
|
||||
|
||||
function recordPreloadFailure(preloadKey: string, now: number): PreloadCooldownEntry {
|
||||
const existing = preloadCooldown.get(preloadKey);
|
||||
const consecutiveFailures = (existing?.consecutiveFailures ?? 0) + 1;
|
||||
const entry: PreloadCooldownEntry = {
|
||||
consecutiveFailures,
|
||||
untilMs: now + computePreloadBackoffMs(consecutiveFailures),
|
||||
};
|
||||
preloadCooldown.set(preloadKey, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function isPreloadCoolingDown(preloadKey: string, now: number): PreloadCooldownEntry | undefined {
|
||||
const entry = preloadCooldown.get(preloadKey);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.untilMs <= now) {
|
||||
preloadCooldown.delete(preloadKey);
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** Test-only hook for clearing preload cooldown state between cases. */
|
||||
export function __resetLmstudioPreloadCooldownForTest(): void {
|
||||
preloadCooldown.clear();
|
||||
preloadInFlight.clear();
|
||||
}
|
||||
|
||||
function normalizeLmstudioModelKey(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (trimmed.toLowerCase().startsWith("lmstudio/")) {
|
||||
@@ -193,67 +131,29 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext):
|
||||
modelKey,
|
||||
requestedContextLength,
|
||||
});
|
||||
|
||||
const cooldownEntry = isPreloadCoolingDown(preloadKey, Date.now());
|
||||
const existing = preloadInFlight.get(preloadKey);
|
||||
const preloadPromise: Promise<void> | undefined =
|
||||
const preloadPromise =
|
||||
existing ??
|
||||
(cooldownEntry
|
||||
? undefined
|
||||
: (() => {
|
||||
const created = ensureLmstudioModelLoadedBestEffort({
|
||||
baseUrl: resolvedBaseUrl,
|
||||
modelKey,
|
||||
requestedContextLength,
|
||||
options,
|
||||
ctx,
|
||||
modelHeaders: resolveModelHeaders(model),
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
recordPreloadSuccess(preloadKey);
|
||||
},
|
||||
(error) => {
|
||||
const entry = recordPreloadFailure(preloadKey, Date.now());
|
||||
throw Object.assign(new Error("preload-failed"), {
|
||||
cause: error,
|
||||
consecutiveFailures: entry.consecutiveFailures,
|
||||
cooldownMs: entry.untilMs - Date.now(),
|
||||
});
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
preloadInFlight.delete(preloadKey);
|
||||
});
|
||||
preloadInFlight.set(preloadKey, created);
|
||||
return created;
|
||||
})());
|
||||
ensureLmstudioModelLoadedBestEffort({
|
||||
baseUrl: resolvedBaseUrl,
|
||||
modelKey,
|
||||
requestedContextLength,
|
||||
options,
|
||||
ctx,
|
||||
modelHeaders: resolveModelHeaders(model),
|
||||
}).finally(() => {
|
||||
preloadInFlight.delete(preloadKey);
|
||||
});
|
||||
if (!existing) {
|
||||
preloadInFlight.set(preloadKey, preloadPromise);
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
if (preloadPromise) {
|
||||
try {
|
||||
await preloadPromise;
|
||||
} catch (error) {
|
||||
const annotated = error as {
|
||||
cause?: unknown;
|
||||
consecutiveFailures?: number;
|
||||
cooldownMs?: number;
|
||||
};
|
||||
const cause = annotated.cause ?? error;
|
||||
const failures = annotated.consecutiveFailures ?? 1;
|
||||
const cooldownSec = Math.max(
|
||||
0,
|
||||
Math.round((annotated.cooldownMs ?? 0) / 1000),
|
||||
);
|
||||
log.warn(
|
||||
`LM Studio inference preload failed for "${modelKey}" (${failures} consecutive failure${
|
||||
failures === 1 ? "" : "s"
|
||||
}, next preload attempt skipped for ~${cooldownSec}s); continuing without preload: ${String(cause)}`,
|
||||
);
|
||||
}
|
||||
} else if (cooldownEntry) {
|
||||
log.debug(
|
||||
`LM Studio inference preload for "${modelKey}" skipped while backoff active (${cooldownEntry.consecutiveFailures} prior failures)`,
|
||||
try {
|
||||
await preloadPromise;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`LM Studio inference preload failed for "${modelKey}"; continuing without preload: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
// LM Studio uses OpenAI-compatible streaming usage payloads when requested via
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { normalizeMatrixAllowList, resolveMatrixAllowListMatch } from "./allowlist.js";
|
||||
import type { MatrixAllowListMatch } from "./allowlist.js";
|
||||
|
||||
type MatrixCommandAuthorizer = {
|
||||
configured: boolean;
|
||||
allowed: boolean;
|
||||
};
|
||||
|
||||
type MatrixMonitorAllowListMatch = {
|
||||
allowed: boolean;
|
||||
matchKey?: string;
|
||||
matchSource?: "wildcard" | "id" | "prefixed-id" | "prefixed-user";
|
||||
};
|
||||
|
||||
export type MatrixMonitorAccessState = {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
effectiveRoomUsers: string[];
|
||||
groupAllowConfigured: boolean;
|
||||
directAllowMatch: MatrixMonitorAllowListMatch;
|
||||
roomUserMatch: MatrixMonitorAllowListMatch | null;
|
||||
groupAllowMatch: MatrixMonitorAllowListMatch | null;
|
||||
directAllowMatch: MatrixAllowListMatch;
|
||||
roomUserMatch: MatrixAllowListMatch | null;
|
||||
groupAllowMatch: MatrixAllowListMatch | null;
|
||||
commandAuthorizers: [MatrixCommandAuthorizer, MatrixCommandAuthorizer, MatrixCommandAuthorizer];
|
||||
};
|
||||
|
||||
|
||||
@@ -446,11 +446,10 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
});
|
||||
|
||||
it("blocks room control commands from DM-only paired senders", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => ["@user:example.org"]);
|
||||
const { handler, finalizeInboundContext, recordInboundSession } =
|
||||
createMatrixHandlerTestHarness({
|
||||
isDirectMessage: false,
|
||||
readAllowFromStore,
|
||||
readAllowFromStore: vi.fn(async () => ["@user:example.org"]),
|
||||
roomsConfig: {
|
||||
"!room:example.org": { requireMention: false },
|
||||
},
|
||||
@@ -474,7 +473,6 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
|
||||
expect(recordInboundSession).not.toHaveBeenCalled();
|
||||
expect(finalizeInboundContext).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("processes room messages mentioned via displayName in formatted_body", async () => {
|
||||
|
||||
@@ -586,7 +586,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
senderNamePromise ??= getMemberDisplayName(roomId, senderId).catch(() => senderId);
|
||||
return await senderNamePromise;
|
||||
};
|
||||
const storeAllowFrom = isDirectMessage ? await readStoreAllowFrom() : [];
|
||||
const storeAllowFrom = await readStoreAllowFrom();
|
||||
const roomUsers = roomConfig?.users ?? [];
|
||||
const accessState = resolveMatrixMonitorAccessState({
|
||||
allowFrom,
|
||||
|
||||
@@ -27,11 +27,6 @@ const LIGHT_DREAMING_TEST_CONFIG: OpenClawConfig = {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
timezone: "UTC",
|
||||
// The existing tests in this file were written when "inline" was the
|
||||
// default storage mode and assert against `memory/<day>.md` directly.
|
||||
// Pin the storage mode explicitly so they keep covering inline mode
|
||||
// after the default flipped to "separate" in #66328.
|
||||
storage: { mode: "inline", separateReports: false },
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
@@ -310,10 +305,6 @@ describe("memory-core dreaming phases", () => {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
// This test asserts inline-mode side effects on the daily
|
||||
// file; pin storage explicitly after the default flipped to
|
||||
// "separate" in #66328.
|
||||
storage: { mode: "inline", separateReports: false },
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
@@ -729,119 +720,6 @@ describe("memory-core dreaming phases", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips dreaming transcripts when the session store identifies them before bootstrap lands", async () => {
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(workspaceDir, ".state"));
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
const transcriptPath = path.join(sessionsDir, "dreaming-narrative.jsonl");
|
||||
await fs.writeFile(
|
||||
transcriptPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "user",
|
||||
timestamp: "2026-04-05T18:01:00.000Z",
|
||||
content: [
|
||||
{ type: "text", text: "Write a dream diary entry from these memory fragments." },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: "2026-04-05T18:02:00.000Z",
|
||||
content: [{ type: "text", text: "I drift through the same archive again." }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(sessionsDir, "sessions.json"),
|
||||
JSON.stringify({
|
||||
"agent:main:dreaming-narrative-light-1775894400455": {
|
||||
sessionId: "dreaming-narrative",
|
||||
sessionFile: transcriptPath,
|
||||
updatedAt: Date.parse("2026-04-05T18:05:00.000Z"),
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const mtime = new Date("2026-04-05T18:05:00.000Z");
|
||||
await fs.utimes(transcriptPath, mtime, mtime);
|
||||
|
||||
const { beforeAgentReply } = createHarness(
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
list: [{ id: "main", workspace: workspaceDir }],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
limit: 20,
|
||||
lookbackDays: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
try {
|
||||
await beforeAgentReply(
|
||||
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
|
||||
{ trigger: "heartbeat", workspaceDir },
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
|
||||
await expect(
|
||||
fs.access(path.join(workspaceDir, "memory", ".dreams", "session-corpus", "2026-04-05.txt")),
|
||||
).rejects.toMatchObject({ code: "ENOENT" });
|
||||
|
||||
const sessionIngestion = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(workspaceDir, "memory", ".dreams", "session-ingestion.json"),
|
||||
"utf-8",
|
||||
),
|
||||
) as {
|
||||
files: Record<
|
||||
string,
|
||||
{
|
||||
lineCount: number;
|
||||
lastContentLine: number;
|
||||
contentHash: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
expect(Object.keys(sessionIngestion.files)).toHaveLength(1);
|
||||
expect(Object.values(sessionIngestion.files)).toEqual([
|
||||
expect.objectContaining({
|
||||
lineCount: 0,
|
||||
lastContentLine: 0,
|
||||
contentHash: expect.any(String),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not reread unchanged dreaming-generated transcripts after checkpointing skip state", async () => {
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
@@ -1770,94 +1648,4 @@ describe("memory-core dreaming phases", () => {
|
||||
"The traces braided themselves into a map.",
|
||||
);
|
||||
});
|
||||
|
||||
it("increments dailyCount when the same daily file is re-ingested on a later day", async () => {
|
||||
// Regression test for #67061: dayBucket used the file date instead of the
|
||||
// ingestion date, so re-ingesting the same file on a different day was
|
||||
// treated as a duplicate and dailyCount stayed at 1.
|
||||
const workspaceDir = await createDreamingWorkspace();
|
||||
// Write a daily note dated 2026-04-03 (two days before the base test time).
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "memory", "2026-04-03.md"),
|
||||
["# 2026-04-03", "", "- Move backups to S3 Glacier.", "- Keep retention at 365 days."].join(
|
||||
"\n",
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const configForTest: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
enabled: true,
|
||||
phases: {
|
||||
light: {
|
||||
enabled: true,
|
||||
limit: 20,
|
||||
lookbackDays: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// First ingestion on 2026-04-05.
|
||||
const day1Ms = Date.parse("2026-04-05T10:00:00.000Z");
|
||||
const { beforeAgentReply: reply1 } = createHarness(configForTest, workspaceDir);
|
||||
await withDreamingTestClock(async () => {
|
||||
vi.setSystemTime(new Date(day1Ms));
|
||||
await reply1(
|
||||
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
|
||||
{ trigger: "heartbeat", workspaceDir },
|
||||
);
|
||||
});
|
||||
|
||||
const after1 = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
nowMs: day1Ms,
|
||||
});
|
||||
expect(after1).toHaveLength(1);
|
||||
expect(after1[0]?.dailyCount).toBe(1);
|
||||
|
||||
// Clear the daily ingestion checkpoint so the file is re-read on the second
|
||||
// sweep (simulating a new day where the same lookback window still covers
|
||||
// this file).
|
||||
const dailyStatePath = path.join(workspaceDir, "memory", ".dreams", "daily-ingestion.json");
|
||||
try {
|
||||
await fs.unlink(dailyStatePath);
|
||||
} catch {
|
||||
// ignore if not created
|
||||
}
|
||||
|
||||
// Second ingestion on 2026-04-06 (next day).
|
||||
const day2Ms = Date.parse("2026-04-06T10:00:00.000Z");
|
||||
const { beforeAgentReply: reply2 } = createHarness(configForTest, workspaceDir);
|
||||
await withDreamingTestClock(async () => {
|
||||
vi.setSystemTime(new Date(day2Ms));
|
||||
await reply2(
|
||||
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
|
||||
{ trigger: "heartbeat", workspaceDir },
|
||||
);
|
||||
});
|
||||
|
||||
const after2 = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
nowMs: day2Ms,
|
||||
});
|
||||
expect(after2).toHaveLength(1);
|
||||
// With the fix, dailyCount should be 2 because the ingestion date changed.
|
||||
// Before the fix, it stayed at 1 because dayBucket was the file date.
|
||||
expect(after2[0]?.dailyCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memo
|
||||
import {
|
||||
buildSessionEntry,
|
||||
listSessionFilesForAgent,
|
||||
loadDreamingNarrativeTranscriptPathSetForAgent,
|
||||
normalizeSessionTranscriptPathForComparison,
|
||||
parseUsageCountedSessionIdFromFileName,
|
||||
sessionPathForFile,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
|
||||
@@ -690,25 +688,13 @@ async function collectSessionIngestionBatches(params: {
|
||||
const nextSeenMessages: Record<string, string[]> = { ...params.state.seenMessages };
|
||||
let changed = false;
|
||||
|
||||
const sessionFiles: Array<{
|
||||
agentId: string;
|
||||
absolutePath: string;
|
||||
generatedByDreamingNarrative: boolean;
|
||||
sessionPath: string;
|
||||
}> = [];
|
||||
const sessionFiles: Array<{ agentId: string; absolutePath: string; sessionPath: string }> = [];
|
||||
for (const agentId of agentIds) {
|
||||
const files = await listSessionFilesForAgent(agentId);
|
||||
const dreamingTranscriptPaths =
|
||||
files.length > 0
|
||||
? loadDreamingNarrativeTranscriptPathSetForAgent(agentId)
|
||||
: new Set<string>();
|
||||
for (const absolutePath of files) {
|
||||
sessionFiles.push({
|
||||
agentId,
|
||||
absolutePath,
|
||||
generatedByDreamingNarrative: dreamingTranscriptPaths.has(
|
||||
normalizeSessionTranscriptPathForComparison(absolutePath),
|
||||
),
|
||||
sessionPath: sessionPathForFile(absolutePath),
|
||||
});
|
||||
}
|
||||
@@ -765,9 +751,7 @@ async function collectSessionIngestionBatches(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = await buildSessionEntry(file.absolutePath, {
|
||||
generatedByDreamingNarrative: file.generatedByDreamingNarrative,
|
||||
});
|
||||
const entry = await buildSessionEntry(file.absolutePath);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
@@ -970,7 +954,6 @@ async function ingestSessionTranscriptSignals(params: {
|
||||
timezone: params.timezone,
|
||||
state,
|
||||
});
|
||||
const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone);
|
||||
for (const batch of collected.batches) {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -978,7 +961,7 @@ async function ingestSessionTranscriptSignals(params: {
|
||||
results: batch.results,
|
||||
signalType: "daily",
|
||||
dedupeByQueryPerDay: true,
|
||||
dayBucket: ingestionDayBucket,
|
||||
dayBucket: batch.day,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
});
|
||||
@@ -1130,7 +1113,6 @@ async function ingestDailyMemorySignals(params: {
|
||||
nowMs: params.nowMs,
|
||||
state,
|
||||
});
|
||||
const ingestionDayBucket = formatMemoryDreamingDay(params.nowMs, params.timezone);
|
||||
for (const batch of collected.batches) {
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -1138,7 +1120,7 @@ async function ingestDailyMemorySignals(params: {
|
||||
results: batch.results,
|
||||
signalType: "daily",
|
||||
dedupeByQueryPerDay: true,
|
||||
dayBucket: ingestionDayBucket,
|
||||
dayBucket: batch.day,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
});
|
||||
@@ -1240,7 +1222,7 @@ export async function seedHistoricalDailyMemorySignals(params: {
|
||||
results,
|
||||
signalType: "daily",
|
||||
dedupeByQueryPerDay: true,
|
||||
dayBucket: formatMemoryDreamingDay(params.nowMs, params.timezone),
|
||||
dayBucket: entry.day,
|
||||
nowMs: params.nowMs,
|
||||
timezone: params.timezone,
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ describe("short-term dreaming config", () => {
|
||||
maxAgeDays: 30,
|
||||
verboseLogging: false,
|
||||
storage: {
|
||||
mode: "separate",
|
||||
mode: "inline",
|
||||
separateReports: false,
|
||||
},
|
||||
});
|
||||
@@ -223,7 +223,7 @@ describe("short-term dreaming config", () => {
|
||||
maxAgeDays: 30,
|
||||
verboseLogging: true,
|
||||
storage: {
|
||||
mode: "separate",
|
||||
mode: "inline",
|
||||
separateReports: false,
|
||||
},
|
||||
});
|
||||
@@ -259,7 +259,7 @@ describe("short-term dreaming config", () => {
|
||||
maxAgeDays: 45,
|
||||
verboseLogging: false,
|
||||
storage: {
|
||||
mode: "separate",
|
||||
mode: "inline",
|
||||
separateReports: false,
|
||||
},
|
||||
});
|
||||
@@ -294,7 +294,7 @@ describe("short-term dreaming config", () => {
|
||||
maxAgeDays: 30,
|
||||
verboseLogging: false,
|
||||
storage: {
|
||||
mode: "separate",
|
||||
mode: "inline",
|
||||
separateReports: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -615,7 +615,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
|
||||
bodyLines: reportLines,
|
||||
nowMs: sweepNowMs,
|
||||
timezone: params.config.timezone,
|
||||
storage: params.config.storage ?? { mode: "separate", separateReports: false },
|
||||
storage: params.config.storage ?? { mode: "inline", separateReports: false },
|
||||
});
|
||||
// Generate dream diary narrative from promoted memories.
|
||||
if (params.subagent && (candidates.length > 0 || applied.applied > 0)) {
|
||||
|
||||
@@ -9,14 +9,7 @@ export type SearchImpl = (opts?: {
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
}) => Promise<unknown[]>;
|
||||
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
|
||||
export type MemoryReadResult = {
|
||||
text: string;
|
||||
path: string;
|
||||
truncated?: boolean;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
nextFrom?: number;
|
||||
};
|
||||
export type MemoryReadResult = { text: string; path: string };
|
||||
type MemoryBackend = "builtin" | "qmd";
|
||||
|
||||
let backend: MemoryBackend = "builtin";
|
||||
@@ -26,8 +19,6 @@ let searchImpl: SearchImpl = async () => [];
|
||||
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
});
|
||||
|
||||
const stubManager = {
|
||||
@@ -103,12 +94,7 @@ export function resetMemoryToolMockState(overrides?: {
|
||||
searchImpl = overrides?.searchImpl ?? (async () => []);
|
||||
readFileImpl =
|
||||
overrides?.readFileImpl ??
|
||||
(async (params: MemoryReadParams) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
}));
|
||||
(async (params: MemoryReadParams) => ({ text: "", path: params.relPath }));
|
||||
vi.clearAllMocks();
|
||||
}
|
||||
|
||||
|
||||
@@ -376,9 +376,6 @@ describe("memory index", () => {
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(memoryDir, "2026-01-12.md"),
|
||||
@@ -414,9 +411,6 @@ describe("memory index", () => {
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
await fs.writeFile(memoryPath, "Project Nebula stale codename: ORBIT-9.\n", "utf8");
|
||||
@@ -484,9 +478,6 @@ describe("memory index", () => {
|
||||
const manager = requireManager(result);
|
||||
managersForCleanup.add(manager);
|
||||
resetManagerForTest(manager);
|
||||
if (!manager.status().fts?.available) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
@@ -9,26 +9,9 @@ import { searchKeyword } from "./manager-search.js";
|
||||
describe("searchKeyword trigram fallback", () => {
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
|
||||
function supportsTrigramFts(): boolean {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
try {
|
||||
const result = ensureMemoryIndexSchema({
|
||||
db,
|
||||
embeddingCacheTable: "embedding_cache",
|
||||
cacheEnabled: false,
|
||||
ftsTable: "chunks_fts",
|
||||
ftsEnabled: true,
|
||||
ftsTokenizer: "trigram",
|
||||
});
|
||||
return result.ftsAvailable;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function createTrigramDb() {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
const result = ensureMemoryIndexSchema({
|
||||
ensureMemoryIndexSchema({
|
||||
db,
|
||||
embeddingCacheTable: "embedding_cache",
|
||||
cacheEnabled: false,
|
||||
@@ -36,10 +19,6 @@ describe("searchKeyword trigram fallback", () => {
|
||||
ftsEnabled: true,
|
||||
ftsTokenizer: "trigram",
|
||||
});
|
||||
if (!result.ftsAvailable) {
|
||||
db.close();
|
||||
throw new Error(`FTS5 trigram unavailable: ${result.ftsError ?? "unknown error"}`);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -74,9 +53,7 @@ describe("searchKeyword trigram fallback", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const itWithTrigramFts = supportsTrigramFts() ? it : it.skip;
|
||||
|
||||
itWithTrigramFts("finds short Chinese queries with substring fallback", async () => {
|
||||
it("finds short Chinese queries with substring fallback", async () => {
|
||||
const results = await runSearch({
|
||||
rows: [{ id: "1", path: "memory/zh.md", text: "今天玩成语接龙游戏" }],
|
||||
query: "成语",
|
||||
@@ -85,7 +62,7 @@ describe("searchKeyword trigram fallback", () => {
|
||||
expect(results[0]?.textScore).toBe(1);
|
||||
});
|
||||
|
||||
itWithTrigramFts("finds short Japanese and Korean queries with substring fallback", async () => {
|
||||
it("finds short Japanese and Korean queries with substring fallback", async () => {
|
||||
const japaneseResults = await runSearch({
|
||||
rows: [{ id: "jp", path: "memory/jp.md", text: "今日はしりとり大会" }],
|
||||
query: "しり とり",
|
||||
@@ -99,22 +76,19 @@ describe("searchKeyword trigram fallback", () => {
|
||||
expect(koreanResults.map((row) => row.id)).toEqual(["ko"]);
|
||||
});
|
||||
|
||||
itWithTrigramFts(
|
||||
"keeps MATCH semantics for long trigram terms while requiring short CJK substrings",
|
||||
async () => {
|
||||
const results = await runSearch({
|
||||
rows: [
|
||||
{ id: "match", path: "memory/good.md", text: "今天玩成语接龙游戏" },
|
||||
{ id: "partial", path: "memory/partial.md", text: "今天玩成语接龙" },
|
||||
],
|
||||
query: "成语接龙 游戏",
|
||||
});
|
||||
expect(results.map((row) => row.id)).toEqual(["match"]);
|
||||
expect(results[0]?.textScore).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
it("keeps MATCH semantics for long trigram terms while requiring short CJK substrings", async () => {
|
||||
const results = await runSearch({
|
||||
rows: [
|
||||
{ id: "match", path: "memory/good.md", text: "今天玩成语接龙游戏" },
|
||||
{ id: "partial", path: "memory/partial.md", text: "今天玩成语接龙" },
|
||||
],
|
||||
query: "成语接龙 游戏",
|
||||
});
|
||||
expect(results.map((row) => row.id)).toEqual(["match"]);
|
||||
expect(results[0]?.textScore).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
itWithTrigramFts("applies fallback lexical boosts without exceeding bounded scores", async () => {
|
||||
it("applies fallback lexical boosts without exceeding bounded scores", async () => {
|
||||
const results = await runSearch({
|
||||
rows: [
|
||||
{
|
||||
@@ -159,7 +133,7 @@ describe("searchKeyword trigram fallback", () => {
|
||||
expect(boostedById.get("weak")?.score).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
itWithTrigramFts("does not overweight repeated query tokens in fallback scoring", async () => {
|
||||
it("does not overweight repeated query tokens in fallback scoring", async () => {
|
||||
const unique = await runSearch({
|
||||
rows: [{ id: "1", path: "memory/project.md", text: "Project memory context." }],
|
||||
query: "project memory context",
|
||||
|
||||
@@ -56,92 +56,7 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
from: 2,
|
||||
lines: 1,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: "line 2\n\n[More content available. Use from=3 to continue.]",
|
||||
path: relPath,
|
||||
from: 2,
|
||||
lines: 1,
|
||||
truncated: true,
|
||||
nextFrom: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a default-sized excerpt when no line range is provided", async () => {
|
||||
const relPath = "memory/default-window.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 150 }, (_, index) => `line ${index + 1}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.path).toBe(relPath);
|
||||
expect(result.from).toBe(1);
|
||||
expect(result.lines).toBe(120);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBe(121);
|
||||
expect(result.text).toContain("line 1");
|
||||
expect(result.text).toContain("line 120");
|
||||
expect(result.text).not.toContain("line 121");
|
||||
expect(result.text).toContain("Use from=121 to continue.");
|
||||
});
|
||||
|
||||
it("returns a bounded window when from is provided without lines", async () => {
|
||||
const relPath = "memory/from-only.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 160 }, (_, index) => `line ${index + 1}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
from: 21,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(21);
|
||||
expect(result.lines).toBe(120);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBe(141);
|
||||
expect(result.text).toContain("line 21");
|
||||
expect(result.text).toContain("line 140");
|
||||
expect(result.text).not.toContain("line 141");
|
||||
});
|
||||
|
||||
it("honors injected defaultLines and maxChars overrides", async () => {
|
||||
const relPath = "memory/agent-limits.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 40 }, (_, index) => `line ${index + 1}: ${"x".repeat(40)}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
defaultLines: 5,
|
||||
maxChars: 220,
|
||||
});
|
||||
|
||||
expect(result.from).toBe(1);
|
||||
expect(result.lines).toBeLessThanOrEqual(5);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBeGreaterThan(1);
|
||||
expect(result.text).toContain("Use from=");
|
||||
expect(result).toEqual({ text: "line 2", path: relPath });
|
||||
});
|
||||
|
||||
it("returns empty text when the requested slice is past EOF", async () => {
|
||||
@@ -157,87 +72,7 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
from: 10,
|
||||
lines: 5,
|
||||
});
|
||||
expect(result).toEqual({ text: "", path: relPath, from: 10, lines: 0 });
|
||||
});
|
||||
|
||||
it("caps returned text to the default max chars and exposes continuation metadata", async () => {
|
||||
const relPath = "memory/char-cap.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
absPath,
|
||||
Array.from({ length: 200 }, (_, index) => `${index + 1}: ${"x".repeat(200)}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBeGreaterThan(1);
|
||||
expect(result.lines).toBeLessThan(120);
|
||||
expect(result.text.length).toBeLessThanOrEqual(12_000 + 64);
|
||||
expect(result.text).toContain("Use from=");
|
||||
});
|
||||
|
||||
it("suggests read fallback for pathological single-line truncation in workspace memory files", async () => {
|
||||
const relPath = "memory/oversized-line.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, `1: ${"x".repeat(20_000)}`, "utf-8");
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.lines).toBe(1);
|
||||
expect(result.nextFrom).toBeUndefined();
|
||||
expect(result.text).toContain("use read on the source file");
|
||||
expect(result.text).not.toContain("Use from=");
|
||||
});
|
||||
|
||||
it("does not advertise line continuation when a single oversized line is cut mid-line", async () => {
|
||||
const relPath = "memory/oversized-line-with-tail.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, [`1: ${"x".repeat(20_000)}`, "line 2"].join("\n"), "utf-8");
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.lines).toBe(1);
|
||||
expect(result.nextFrom).toBeUndefined();
|
||||
expect(result.text).not.toContain("Use from=");
|
||||
});
|
||||
|
||||
it("omits truncation metadata when the full excerpt fits and no more lines remain", async () => {
|
||||
const relPath = "memory/complete.md";
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
await fs.mkdir(path.dirname(absPath), { recursive: true });
|
||||
await fs.writeFile(absPath, ["alpha", "beta", "gamma"].join("\n"), "utf-8");
|
||||
|
||||
const result = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [],
|
||||
relPath,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "alpha\nbeta\ngamma",
|
||||
path: relPath,
|
||||
from: 1,
|
||||
lines: 3,
|
||||
});
|
||||
expect(result).toEqual({ text: "", path: relPath });
|
||||
});
|
||||
|
||||
it("returns empty text when the file disappears after stat", async () => {
|
||||
@@ -286,7 +121,6 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
it("allows additional memory paths and blocks symlinks", async () => {
|
||||
await fs.mkdir(extraDir, { recursive: true });
|
||||
await fs.writeFile(path.join(extraDir, "extra.md"), "Extra content.");
|
||||
await fs.writeFile(path.join(extraDir, "oversized.md"), `1: ${"y".repeat(20_000)}`);
|
||||
|
||||
await expect(
|
||||
readMemoryFile({
|
||||
@@ -297,18 +131,8 @@ describe("MemoryIndexManager.readFile", () => {
|
||||
).resolves.toEqual({
|
||||
path: "extra/extra.md",
|
||||
text: "Extra content.",
|
||||
from: 1,
|
||||
lines: 1,
|
||||
});
|
||||
|
||||
const oversized = await readMemoryFile({
|
||||
workspaceDir,
|
||||
extraPaths: [extraDir],
|
||||
relPath: "extra/oversized.md",
|
||||
});
|
||||
expect(oversized.truncated).toBe(true);
|
||||
expect(oversized.text).not.toContain("use read on the source file");
|
||||
|
||||
const linkPath = path.join(extraDir, "linked.md");
|
||||
let symlinkOk = true;
|
||||
try {
|
||||
|
||||
@@ -254,8 +254,6 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
|
||||
path: actualRelative,
|
||||
text: "line-1\nline-2\nline-3",
|
||||
from: 1,
|
||||
lines: 3,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,8 +324,6 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
|
||||
path: `qmd/${collectionName}/${actualRelative}`,
|
||||
text: "vault memory",
|
||||
from: 1,
|
||||
lines: 1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -385,8 +381,6 @@ describe("QmdMemoryManager slugified path resolution", () => {
|
||||
await expect(manager.readFile({ relPath: results[0].path })).resolves.toEqual({
|
||||
path: exactRelative,
|
||||
text: "exact slugified path",
|
||||
from: 1,
|
||||
lines: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2318,7 +2318,6 @@ describe("QmdMemoryManager", () => {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
let expectedLimit = 0;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
@@ -2326,7 +2325,7 @@ describe("QmdMemoryManager", () => {
|
||||
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
|
||||
expect(callArgs).toMatchObject({
|
||||
query: "hello",
|
||||
limit: expectedLimit,
|
||||
limit: 6,
|
||||
minScore: 0,
|
||||
collection: "workspace-main",
|
||||
});
|
||||
@@ -2339,8 +2338,7 @@ describe("QmdMemoryManager", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager, resolved } = await createManager();
|
||||
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
|
||||
const { manager } = await createManager();
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
await manager.close();
|
||||
});
|
||||
@@ -2550,7 +2548,6 @@ describe("QmdMemoryManager", () => {
|
||||
} as OpenClawConfig;
|
||||
|
||||
const selectors: string[] = [];
|
||||
let expectedLimit = 0;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
@@ -2567,7 +2564,7 @@ describe("QmdMemoryManager", () => {
|
||||
expect(selector).toBe("qmd.search");
|
||||
expect(callArgs).toMatchObject({
|
||||
query: "hello",
|
||||
limit: expectedLimit,
|
||||
limit: 6,
|
||||
minScore: 0,
|
||||
});
|
||||
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
|
||||
@@ -2577,8 +2574,7 @@ describe("QmdMemoryManager", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager, resolved } = await createManager();
|
||||
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
|
||||
const { manager } = await createManager();
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
|
||||
expect(selectors).toEqual(["qmd.query", "qmd.search", "qmd.search"]);
|
||||
@@ -2607,7 +2603,6 @@ describe("QmdMemoryManager", () => {
|
||||
|
||||
const selectors: string[] = [];
|
||||
const collections: string[] = [];
|
||||
let expectedLimit = 0;
|
||||
spawnMock.mockImplementation((cmd: string, args: string[]) => {
|
||||
const child = createMockChild({ autoClose: false });
|
||||
if (isMcporterCommand(cmd) && args[0] === "call") {
|
||||
@@ -2616,7 +2611,7 @@ describe("QmdMemoryManager", () => {
|
||||
collections.push(String(callArgs.collection ?? ""));
|
||||
expect(callArgs).toMatchObject({
|
||||
query: "hello",
|
||||
limit: expectedLimit,
|
||||
limit: 6,
|
||||
minScore: 0,
|
||||
});
|
||||
expect(callArgs).not.toHaveProperty("searches");
|
||||
@@ -2628,8 +2623,7 @@ describe("QmdMemoryManager", () => {
|
||||
return child;
|
||||
});
|
||||
|
||||
const { manager, resolved } = await createManager();
|
||||
expectedLimit = resolved.qmd?.limits.maxResults ?? 0;
|
||||
const { manager } = await createManager();
|
||||
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
|
||||
|
||||
expect(selectors).toEqual(["qmd.hybrid_search", "qmd.hybrid_search"]);
|
||||
@@ -3590,45 +3584,13 @@ describe("QmdMemoryManager", () => {
|
||||
const { manager } = await createManager();
|
||||
|
||||
const result = await manager.readFile({ relPath, from: 10, lines: 3 });
|
||||
expect(result).toEqual({
|
||||
path: relPath,
|
||||
text: "line-10\nline-11\nline-12\n\n[More content available. Use from=13 to continue.]",
|
||||
from: 10,
|
||||
lines: 3,
|
||||
truncated: true,
|
||||
nextFrom: 13,
|
||||
});
|
||||
expect(result.text).toBe("line-10\nline-11\nline-12");
|
||||
expect(readFileSpy).not.toHaveBeenCalled();
|
||||
|
||||
await manager.close();
|
||||
readFileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns a bounded default excerpt for qmd memory reads without explicit lines", async () => {
|
||||
const relPath = path.join("memory", "default-window.md");
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, relPath),
|
||||
Array.from({ length: 150 }, (_, index) => `line-${index + 1}`).join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { manager } = await createManager();
|
||||
|
||||
const result = await manager.readFile({ relPath });
|
||||
expect(result.path).toBe(relPath);
|
||||
expect(result.from).toBe(1);
|
||||
expect(result.lines).toBe(120);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(result.nextFrom).toBe(121);
|
||||
expect(result.text).toContain("line-1");
|
||||
expect(result.text).toContain("line-120");
|
||||
expect(result.text).not.toContain("line-121");
|
||||
expect(result.text).toContain("Use from=121 to continue.");
|
||||
|
||||
await manager.close();
|
||||
});
|
||||
|
||||
it("returns empty text when qmd files are missing before or during read", async () => {
|
||||
const relPath = path.join("memory", "qmd-window.md");
|
||||
const absPath = path.join(workspaceDir, relPath);
|
||||
@@ -3658,7 +3620,7 @@ describe("QmdMemoryManager", () => {
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
return await realOpen(target, options);
|
||||
return realOpen(target, options);
|
||||
});
|
||||
return () => openSpy.mockRestore();
|
||||
},
|
||||
@@ -4064,8 +4026,6 @@ describe("QmdMemoryManager", () => {
|
||||
expect(readResult).toEqual({
|
||||
path: "qmd/sessions-main/session-1.md",
|
||||
text: "# Session session-1\n\nsession canary\n",
|
||||
from: 1,
|
||||
lines: 4,
|
||||
});
|
||||
} finally {
|
||||
lstatSpy.mockRestore();
|
||||
|
||||
@@ -29,11 +29,7 @@ import {
|
||||
type SessionFileEntry,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
|
||||
import {
|
||||
buildMemoryReadResult,
|
||||
buildMemoryReadResultFromSlice,
|
||||
DEFAULT_MEMORY_READ_LINES,
|
||||
isFileMissingError,
|
||||
type MemoryReadResult,
|
||||
requireNodeSqlite,
|
||||
statRegularFile,
|
||||
type MemoryEmbeddingProbeResult,
|
||||
@@ -47,7 +43,6 @@ import {
|
||||
type ResolvedQmdConfig,
|
||||
type ResolvedQmdMcporterConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
import { resolveAgentContextLimits } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
|
||||
import {
|
||||
localeLowercasePreservingWhitespace,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -1185,7 +1180,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
lines?: number;
|
||||
}): Promise<MemoryReadResult> {
|
||||
}): Promise<{ text: string; path: string }> {
|
||||
const relPath = params.relPath?.trim();
|
||||
if (!relPath) {
|
||||
throw new Error("path required");
|
||||
@@ -1198,38 +1193,18 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (statResult.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
const contextLimits = resolveAgentContextLimits(this.cfg, this.agentId);
|
||||
if (params.from !== undefined || params.lines !== undefined) {
|
||||
const requestedCount = Math.max(
|
||||
1,
|
||||
params.lines ?? contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
);
|
||||
const partial = await this.readPartialText(absPath, params.from, requestedCount);
|
||||
const partial = await this.readPartialText(absPath, params.from, params.lines);
|
||||
if (partial.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
return buildMemoryReadResultFromSlice({
|
||||
selectedLines: partial.selectedLines,
|
||||
relPath,
|
||||
startLine: Math.max(1, params.from ?? 1),
|
||||
moreSourceLinesRemain: partial.moreSourceLinesRemain,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
suggestReadFallback: isDefaultMemoryPath(relPath),
|
||||
});
|
||||
return { text: partial.text, path: relPath };
|
||||
}
|
||||
const full = await this.readFullText(absPath);
|
||||
if (full.missing) {
|
||||
return { text: "", path: relPath };
|
||||
}
|
||||
return buildMemoryReadResult({
|
||||
content: full.text,
|
||||
relPath,
|
||||
from: params.from,
|
||||
lines: params.lines,
|
||||
defaultLines: contextLimits?.memoryGetDefaultLines ?? DEFAULT_MEMORY_READ_LINES,
|
||||
maxChars: contextLimits?.memoryGetMaxChars,
|
||||
suggestReadFallback: isDefaultMemoryPath(relPath),
|
||||
});
|
||||
return { text: full.text, path: relPath };
|
||||
}
|
||||
|
||||
status(): MemoryProviderStatus {
|
||||
@@ -1944,10 +1919,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
absPath: string,
|
||||
from?: number,
|
||||
lines?: number,
|
||||
): Promise<
|
||||
| { missing: true }
|
||||
| { missing: false; selectedLines: string[]; moreSourceLinesRemain: boolean }
|
||||
> {
|
||||
): Promise<{ missing: true } | { missing: false; text: string }> {
|
||||
const start = Math.max(1, from ?? 1);
|
||||
const count = Math.max(1, lines ?? Number.POSITIVE_INFINITY);
|
||||
let handle;
|
||||
@@ -1966,7 +1938,6 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
});
|
||||
const selected: string[] = [];
|
||||
let index = 0;
|
||||
let moreSourceLinesRemain = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
index += 1;
|
||||
@@ -1974,7 +1945,6 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
continue;
|
||||
}
|
||||
if (selected.length >= count) {
|
||||
moreSourceLinesRemain = true;
|
||||
break;
|
||||
}
|
||||
selected.push(line);
|
||||
@@ -1983,11 +1953,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
rl.close();
|
||||
await handle.close();
|
||||
}
|
||||
return {
|
||||
missing: false,
|
||||
selectedLines: selected.slice(0, count),
|
||||
moreSourceLinesRemain,
|
||||
};
|
||||
return { missing: false, text: selected.slice(0, count).join("\n") };
|
||||
}
|
||||
|
||||
private async readFullText(
|
||||
|
||||
@@ -60,12 +60,7 @@ beforeEach(() => {
|
||||
source: "memory" as const,
|
||||
},
|
||||
],
|
||||
readFileImpl: async (params: MemoryReadParams) => ({
|
||||
text: "",
|
||||
path: params.relPath,
|
||||
from: params.from ?? 1,
|
||||
lines: params.lines ?? 120,
|
||||
}),
|
||||
readFileImpl: async (params: MemoryReadParams) => ({ text: "", path: params.relPath }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +155,7 @@ describe("memory tools", () => {
|
||||
|
||||
it("returns empty text without error when file does not exist (ENOENT)", async () => {
|
||||
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
|
||||
return { text: "", path: "memory/2026-02-19.md", from: 1, lines: 0 };
|
||||
return { text: "", path: "memory/2026-02-19.md" };
|
||||
});
|
||||
|
||||
const tool = createMemoryGetToolOrThrow();
|
||||
@@ -169,8 +164,6 @@ describe("memory tools", () => {
|
||||
expect(result.details).toEqual({
|
||||
text: "",
|
||||
path: "memory/2026-02-19.md",
|
||||
from: 1,
|
||||
lines: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,37 +176,11 @@ describe("memory tools", () => {
|
||||
expect(result.details).toEqual({
|
||||
text: "",
|
||||
path: "memory/2026-02-19.md",
|
||||
from: 1,
|
||||
lines: 120,
|
||||
});
|
||||
expect(getReadAgentMemoryFileMockCalls()).toBe(1);
|
||||
expect(getMemorySearchManagerMockCalls()).toBe(0);
|
||||
});
|
||||
|
||||
it("returns truncation metadata and a continuation notice for partial memory_get results", async () => {
|
||||
setMemoryBackend("builtin");
|
||||
setMemoryReadFileImpl(async (params: MemoryReadParams) => ({
|
||||
path: params.relPath,
|
||||
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
|
||||
from: params.from ?? 1,
|
||||
lines: 40,
|
||||
truncated: true,
|
||||
nextFrom: 41,
|
||||
}));
|
||||
|
||||
const tool = createMemoryGetToolOrThrow();
|
||||
const result = await tool.execute("call_partial", { path: "memory/partial.md" });
|
||||
|
||||
expect(result.details).toEqual({
|
||||
path: "memory/partial.md",
|
||||
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
|
||||
from: 1,
|
||||
lines: 40,
|
||||
truncated: true,
|
||||
nextFrom: 41,
|
||||
});
|
||||
});
|
||||
|
||||
it("persists short-term recall events from memory_search tool hits", async () => {
|
||||
const workspaceDir = await createTempWorkspace("memory-tools-recall-");
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringParam,
|
||||
type AnyAgentTool,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type {
|
||||
@@ -180,7 +181,7 @@ async function executeMemoryReadResult<T>(params: {
|
||||
export function createMemorySearchTool(options: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
}) {
|
||||
}): AnyAgentTool | null {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
label: "Memory Search",
|
||||
@@ -214,9 +215,7 @@ export function createMemorySearchTool(options: {
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
let surfacedMemoryResults: Array<
|
||||
Record<string, unknown> & { corpus: "memory"; score: number; path: string }
|
||||
> = [];
|
||||
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: "memory" }> = [];
|
||||
let provider: string | undefined;
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
@@ -321,13 +320,13 @@ export function createMemorySearchTool(options: {
|
||||
export function createMemoryGetTool(options: {
|
||||
config?: OpenClawConfig;
|
||||
agentSessionKey?: string;
|
||||
}) {
|
||||
}): AnyAgentTool | null {
|
||||
return createMemoryTool({
|
||||
options,
|
||||
label: "Memory Get",
|
||||
name: "memory_get",
|
||||
description:
|
||||
"Safe exact excerpt read from MEMORY.md or memory/*.md. Defaults to a bounded excerpt when lines are omitted, includes truncation/continuation info when more content exists, and `corpus=wiki` reads from registered compiled-wiki supplements.",
|
||||
"Safe snippet read from MEMORY.md or memory/*.md with optional from/lines; `corpus=wiki` reads from registered compiled-wiki supplements. Use after search to pull only the needed lines and keep context small.",
|
||||
parameters: MemoryGetSchema,
|
||||
execute:
|
||||
({ cfg, agentId }) =>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"id": "microsoft",
|
||||
"enabledByDefault": true,
|
||||
"contracts": {
|
||||
"speechProviders": ["microsoft"]
|
||||
},
|
||||
|
||||
@@ -208,7 +208,6 @@ const _createGraphCollectionResponse = (value: unknown[]) => createJsonResponse(
|
||||
const createNotFoundResponse = () => new Response("not found", { status: 404 });
|
||||
const createRedirectResponse = (location: string, status = 302) =>
|
||||
new Response(null, { status, headers: { location } });
|
||||
const publicResolve = async () => ({ address: "13.107.136.10" });
|
||||
|
||||
const createOkFetchMock = (contentType: string, payload = "png") =>
|
||||
vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
@@ -224,7 +223,6 @@ const buildDownloadParams = (
|
||||
attachments,
|
||||
maxBytes: DEFAULT_MAX_BYTES,
|
||||
allowHosts: DEFAULT_ALLOW_HOSTS,
|
||||
resolveFn: publicResolve,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
type MSTeamsAttachmentResolveFn,
|
||||
resolveAttachmentFetchPolicy,
|
||||
safeFetchWithPolicy,
|
||||
} from "./shared.js";
|
||||
@@ -60,7 +59,6 @@ async function fetchBotFrameworkAttachmentInfo(params: {
|
||||
accessToken: string;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<BotFrameworkAttachmentInfo | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}`;
|
||||
@@ -77,7 +75,6 @@ async function fetchBotFrameworkAttachmentInfo(params: {
|
||||
url,
|
||||
policy: params.policy,
|
||||
fetchFn: params.fetchFn,
|
||||
resolveFn: params.resolveFn,
|
||||
requestInit: {
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
@@ -112,7 +109,6 @@ async function fetchBotFrameworkAttachmentView(params: {
|
||||
maxBytes: number;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
logger?: MSTeamsAttachmentDownloadLogger;
|
||||
}): Promise<Buffer | undefined> {
|
||||
const url = `${normalizeServiceUrl(params.serviceUrl)}/v3/attachments/${encodeURIComponent(params.attachmentId)}/views/${encodeURIComponent(params.viewId)}`;
|
||||
@@ -124,7 +120,6 @@ async function fetchBotFrameworkAttachmentView(params: {
|
||||
url,
|
||||
policy: params.policy,
|
||||
fetchFn: params.fetchFn,
|
||||
resolveFn: params.resolveFn,
|
||||
requestInit: {
|
||||
headers: ensureUserAgentHeader({ Authorization: `Bearer ${params.accessToken}` }),
|
||||
},
|
||||
@@ -174,7 +169,6 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
@@ -211,7 +205,6 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
accessToken,
|
||||
policy,
|
||||
fetchFn: params.fetchFn,
|
||||
resolveFn: params.resolveFn,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!info) {
|
||||
@@ -246,7 +239,6 @@ export async function downloadMSTeamsBotFrameworkAttachment(params: {
|
||||
maxBytes: params.maxBytes,
|
||||
policy,
|
||||
fetchFn: params.fetchFn,
|
||||
resolveFn: params.resolveFn,
|
||||
logger: params.logger,
|
||||
});
|
||||
if (!buffer) {
|
||||
@@ -304,7 +296,6 @@ export async function downloadMSTeamsBotFrameworkAttachments(params: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
fileNameHint?: string | null;
|
||||
contentTypeHint?: string | null;
|
||||
preserveFilenames?: boolean;
|
||||
@@ -338,7 +329,6 @@ export async function downloadMSTeamsBotFrameworkAttachments(params: {
|
||||
allowHosts: params.allowHosts,
|
||||
authAllowHosts: params.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
resolveFn: params.resolveFn,
|
||||
fileNameHint: params.fileNameHint,
|
||||
contentTypeHint: params.contentTypeHint,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
type MSTeamsAttachmentResolveFn,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
@@ -112,7 +111,6 @@ async function fetchWithAuthFallback(params: {
|
||||
tokenProvider?: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
}): Promise<Response> {
|
||||
const firstAttempt = await safeFetchWithPolicy({
|
||||
@@ -120,7 +118,6 @@ async function fetchWithAuthFallback(params: {
|
||||
policy: params.policy,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: params.requestInit,
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
if (firstAttempt.ok) {
|
||||
return firstAttempt;
|
||||
@@ -150,7 +147,6 @@ async function fetchWithAuthFallback(params: {
|
||||
...params.requestInit,
|
||||
headers: authHeaders,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
if (authAttempt.ok) {
|
||||
return authAttempt;
|
||||
@@ -182,7 +178,6 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
/**
|
||||
@@ -287,7 +282,6 @@ export async function downloadMSTeamsAttachments(params: {
|
||||
tokenProvider: params.tokenProvider,
|
||||
fetchFn: params.fetchFn,
|
||||
requestInit: init,
|
||||
resolveFn: params.resolveFn,
|
||||
policy,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
isUrlAllowed,
|
||||
type MSTeamsAttachmentDownloadLogger,
|
||||
type MSTeamsAttachmentFetchPolicy,
|
||||
type MSTeamsAttachmentResolveFn,
|
||||
normalizeContentType,
|
||||
resolveMediaSsrfPolicy,
|
||||
resolveAttachmentFetchPolicy,
|
||||
@@ -281,7 +280,6 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
/** When true, embeds original filename in stored path for later extraction. */
|
||||
preserveFilenames?: boolean;
|
||||
/** Optional logger used to surface Graph/SharePoint fetch errors. */
|
||||
@@ -396,7 +394,6 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
...init,
|
||||
headers,
|
||||
},
|
||||
resolveFn: params.resolveFn,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -462,7 +459,6 @@ export async function downloadMSTeamsGraphMedia(params: {
|
||||
allowHosts: policy.allowHosts,
|
||||
authAllowHosts: policy.authAllowHosts,
|
||||
fetchFn: params.fetchFn,
|
||||
resolveFn: params.resolveFn,
|
||||
preserveFilenames: params.preserveFilenames,
|
||||
logger: params.logger,
|
||||
});
|
||||
|
||||
@@ -254,18 +254,6 @@ describe("safeFetch", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private hosts with the default resolver", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
safeFetch({
|
||||
url: "https://localhost/file.pdf",
|
||||
allowHosts: ["localhost"],
|
||||
fetchFn: fetchMock as unknown as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("Initial download URL blocked");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks when initial URL DNS resolution fails", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
await expect(
|
||||
|
||||
@@ -402,8 +402,6 @@ export type MSTeamsAttachmentDownloadLogger = {
|
||||
error?: (message: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export type MSTeamsAttachmentResolveFn = (hostname: string) => Promise<{ address: string }>;
|
||||
|
||||
export function resolveAttachmentFetchPolicy(params?: {
|
||||
allowHosts?: string[];
|
||||
authAllowHosts?: string[];
|
||||
@@ -455,7 +453,7 @@ export const isPrivateOrReservedIP: (ip: string) => boolean = isPrivateIpAddress
|
||||
*/
|
||||
export async function resolveAndValidateIP(
|
||||
hostname: string,
|
||||
resolveFn?: MSTeamsAttachmentResolveFn,
|
||||
resolveFn?: (hostname: string) => Promise<{ address: string }>,
|
||||
): Promise<string> {
|
||||
const resolve = resolveFn ?? lookup;
|
||||
let resolved: { address: string };
|
||||
@@ -492,10 +490,10 @@ export async function safeFetch(params: {
|
||||
authorizationAllowHosts?: string[];
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
||||
}): Promise<Response> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const resolveFn = params.resolveFn ?? lookup;
|
||||
const resolveFn = params.resolveFn;
|
||||
const hasDispatcher = Boolean(
|
||||
params.requestInit &&
|
||||
typeof params.requestInit === "object" &&
|
||||
@@ -579,7 +577,7 @@ export async function safeFetchWithPolicy(params: {
|
||||
policy: MSTeamsAttachmentFetchPolicy;
|
||||
fetchFn?: typeof fetch;
|
||||
requestInit?: RequestInit;
|
||||
resolveFn?: MSTeamsAttachmentResolveFn;
|
||||
resolveFn?: (hostname: string) => Promise<{ address: string }>;
|
||||
}): Promise<Response> {
|
||||
return await safeFetch({
|
||||
url: params.url,
|
||||
|
||||
@@ -86,38 +86,6 @@ describe("addParticipantMSTeams", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes role casing and whitespace", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({});
|
||||
|
||||
await addParticipantMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
userId: "user-aad-id-2",
|
||||
role: " OWNER ",
|
||||
});
|
||||
|
||||
expect(mockState.postGraphJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
roles: ["owner"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown roles", async () => {
|
||||
await expect(
|
||||
addParticipantMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
userId: "user-aad-id-2",
|
||||
role: "admin",
|
||||
}),
|
||||
).rejects.toThrow('role must be "member" or "owner"');
|
||||
|
||||
expect(mockState.postGraphJson).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("constructs correct user@odata.bind URL", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({});
|
||||
|
||||
@@ -133,21 +101,6 @@ describe("addParticipantMSTeams", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("escapes user ids before building the OData bind URL", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({});
|
||||
|
||||
await addParticipantMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
userId: "o'hara@example.com",
|
||||
});
|
||||
|
||||
const calledBody = mockState.postGraphJson.mock.calls[0][0].body;
|
||||
expect(calledBody["user@odata.bind"]).toBe(
|
||||
"https://graph.microsoft.com/v1.0/users('o''hara@example.com')",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds member to a channel", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js";
|
||||
import {
|
||||
deleteGraphRequest,
|
||||
escapeOData,
|
||||
fetchGraphJson,
|
||||
patchGraphJson,
|
||||
postGraphJson,
|
||||
@@ -24,19 +23,6 @@ export type AddParticipantMSTeamsResult = {
|
||||
added: { userId: string; chatId: string };
|
||||
};
|
||||
|
||||
type ConversationMemberRole = "member" | "owner";
|
||||
|
||||
function normalizeConversationMemberRole(role: string | undefined): ConversationMemberRole {
|
||||
const normalized = role?.trim().toLowerCase() ?? "";
|
||||
if (!normalized) {
|
||||
return "member";
|
||||
}
|
||||
if (normalized === "member" || normalized === "owner") {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error('MS Teams participant role must be "member" or "owner".');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a user to a chat or channel via Graph API.
|
||||
*/
|
||||
@@ -49,8 +35,8 @@ export async function addParticipantMSTeams(
|
||||
|
||||
const body = {
|
||||
"@odata.type": "#microsoft.graph.aadUserConversationMember",
|
||||
roles: [normalizeConversationMemberRole(params.role)],
|
||||
"user@odata.bind": `https://graph.microsoft.com/v1.0/users('${escapeOData(params.userId)}')`,
|
||||
roles: [params.role || "member"],
|
||||
"user@odata.bind": `https://graph.microsoft.com/v1.0/users('${params.userId}')`,
|
||||
};
|
||||
|
||||
await postGraphJson<unknown>({
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMSTeamsSetupWizardBase, msteamsSetupAdapter } from "./setup-core.js";
|
||||
import { openDelegatedOAuthUrl } from "./setup-surface.js";
|
||||
|
||||
const spawn = vi.hoisted(() => vi.fn());
|
||||
const resolveMSTeamsUserAllowlist = vi.hoisted(() => vi.fn());
|
||||
const resolveMSTeamsChannelAllowlist = vi.hoisted(() => vi.fn());
|
||||
const normalizeSecretInputString = vi.hoisted(() =>
|
||||
@@ -28,19 +25,10 @@ vi.mock("./token.js", () => ({
|
||||
resolveMSTeamsCredentials,
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("msteams setup surface", () => {
|
||||
const msteamsSetupWizard = createMSTeamsSetupWizardBase();
|
||||
|
||||
beforeEach(() => {
|
||||
spawn.mockReset();
|
||||
resolveMSTeamsUserAllowlist.mockReset();
|
||||
resolveMSTeamsChannelAllowlist.mockReset();
|
||||
normalizeSecretInputString.mockClear();
|
||||
@@ -58,21 +46,6 @@ describe("msteams setup surface", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("opens delegated OAuth URLs without invoking a shell", async () => {
|
||||
const url = "https://login.microsoftonline.com/auth?state=$(touch pwned)";
|
||||
const child = new EventEmitter();
|
||||
spawn.mockReturnValue(child);
|
||||
|
||||
const result = openDelegatedOAuthUrl(url);
|
||||
child.emit("exit", 0, null);
|
||||
|
||||
await expect(result).resolves.toBeUndefined();
|
||||
expect(spawn).toHaveBeenCalledWith(process.platform === "darwin" ? "open" : "xdg-open", [url], {
|
||||
stdio: "ignore",
|
||||
shell: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("enables the msteams channel without dropping existing config", () => {
|
||||
expect(
|
||||
msteamsSetupAdapter.applyAccountConfig?.({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import {
|
||||
createTopLevelChannelAllowFromSetter,
|
||||
createTopLevelChannelDmPolicy,
|
||||
@@ -29,22 +29,6 @@ const setMSTeamsGroupPolicy = createTopLevelChannelGroupPolicySetter({
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export function openDelegatedOAuthUrl(url: string): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
const child = spawn(cmd, [url], { stdio: "ignore", shell: false });
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
|
||||
reject(new Error(`${cmd} failed with ${reason}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeGuid(value: string): boolean {
|
||||
return /^[0-9a-fA-F-]{16,}$/.test(value);
|
||||
}
|
||||
@@ -286,7 +270,11 @@ export const msteamsSetupWizard: ChannelSetupWizard = {
|
||||
const tokens = await loginMSTeamsDelegated(
|
||||
{
|
||||
isRemote: shouldUseManualOAuthFlow(isRemote),
|
||||
openUrl: openDelegatedOAuthUrl,
|
||||
openUrl: (url) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const cmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
exec(`${cmd} ${JSON.stringify(url)}`, (err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
log: (msg) => params.prompter.note(msg),
|
||||
note: (msg, title) => params.prompter.note(msg, title),
|
||||
prompt: (msg) => params.prompter.text({ message: msg }),
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildOllamaChatRequest,
|
||||
createConfiguredOllamaStreamFn,
|
||||
@@ -18,17 +9,6 @@ import {
|
||||
resolveOllamaBaseUrlForRun,
|
||||
} from "./stream.js";
|
||||
|
||||
type GuardedFetchCall = {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
policy?: unknown;
|
||||
auditContext?: string;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
describe("buildOllamaChatRequest", () => {
|
||||
it("omits tools when none are provided", () => {
|
||||
expect(
|
||||
@@ -44,17 +24,6 @@ describe("buildOllamaChatRequest", () => {
|
||||
options: { num_ctx: 65536 },
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the ollama/ prefix from chat model ids", () => {
|
||||
expect(
|
||||
buildOllamaChatRequest({
|
||||
modelId: "ollama/qwen3:14b-q8_0",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
}),
|
||||
).toMatchObject({
|
||||
model: "qwen3:14b-q8_0",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToOllamaMessages", () => {
|
||||
@@ -427,23 +396,26 @@ describe("parseNdjsonStream", () => {
|
||||
|
||||
async function withMockNdjsonFetch(
|
||||
lines: string[],
|
||||
run: (fetchMock: typeof fetchWithSsrFGuardMock) => Promise<void>,
|
||||
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
|
||||
): Promise<void> {
|
||||
fetchWithSsrFGuardMock.mockImplementation(async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
const payload = lines.join("\n");
|
||||
return {
|
||||
response: new Response(`${payload}\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
}),
|
||||
release: vi.fn(async () => undefined),
|
||||
};
|
||||
return new Response(`${payload}\n`, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
});
|
||||
});
|
||||
await run(fetchWithSsrFGuardMock);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
try {
|
||||
await run(fetchMock);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
function createControlledNdjsonFetch(): {
|
||||
fetchImpl: () => Promise<{ response: Response; release: () => Promise<void> }>;
|
||||
fetchMock: ReturnType<typeof vi.fn>;
|
||||
pushLine: (line: string) => void;
|
||||
close: () => void;
|
||||
} {
|
||||
@@ -455,12 +427,11 @@ function createControlledNdjsonFetch(): {
|
||||
},
|
||||
});
|
||||
return {
|
||||
fetchImpl: async () => ({
|
||||
response: new Response(body, {
|
||||
fetchMock: vi.fn(async () => {
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/x-ndjson" },
|
||||
}),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
}),
|
||||
pushLine(line: string) {
|
||||
if (!controller) {
|
||||
@@ -477,10 +448,6 @@ function createControlledNdjsonFetch(): {
|
||||
};
|
||||
}
|
||||
|
||||
function getGuardedFetchCall(fetchMock: typeof fetchWithSsrFGuardMock): GuardedFetchCall {
|
||||
return (fetchMock.mock.calls[0]?.[0] as GuardedFetchCall | undefined) ?? { url: "" };
|
||||
}
|
||||
|
||||
async function createOllamaTestStream(params: {
|
||||
baseUrl: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
@@ -620,8 +587,9 @@ describe("createOllamaStreamFn streaming events", () => {
|
||||
});
|
||||
|
||||
it("emits text_end as soon as Ollama switches from text to tool calls", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const controlledFetch = createControlledNdjsonFetch();
|
||||
fetchWithSsrFGuardMock.mockImplementation(controlledFetch.fetchImpl);
|
||||
globalThis.fetch = controlledFetch.fetchMock as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
|
||||
@@ -683,7 +651,7 @@ describe("createOllamaStreamFn streaming events", () => {
|
||||
expect(doneEvent).toMatchObject({ value: undefined, done: true });
|
||||
}
|
||||
} finally {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -745,10 +713,8 @@ describe("createOllamaStreamFn", () => {
|
||||
expect(events.at(-1)?.type).toBe("done");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const request = getGuardedFetchCall(fetchMock);
|
||||
expect(request.url).toBe("http://ollama-host:11434/api/chat");
|
||||
expect(request.auditContext).toBe("ollama-stream.chat");
|
||||
const requestInit = request.init ?? {};
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("http://ollama-host:11434/api/chat");
|
||||
expect(requestInit.signal).toBe(signal);
|
||||
if (typeof requestInit.body !== "string") {
|
||||
throw new Error("Expected string request body");
|
||||
@@ -763,28 +729,6 @@ describe("createOllamaStreamFn", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the default loopback policy when baseUrl is empty", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
|
||||
],
|
||||
async (fetchMock) => {
|
||||
const stream = await createOllamaTestStream({ baseUrl: "" });
|
||||
|
||||
const events = await collectStreamEvents(stream);
|
||||
expect(events.at(-1)?.type).toBe("done");
|
||||
|
||||
const request = getGuardedFetchCall(fetchMock);
|
||||
expect(request.url).toBe("http://127.0.0.1:11434/api/chat");
|
||||
expect(request.policy).toMatchObject({
|
||||
hostnameAllowlist: ["127.0.0.1"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("merges default headers and allows request headers to override them", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
@@ -809,7 +753,7 @@ describe("createOllamaStreamFn", () => {
|
||||
const events = await collectStreamEvents(stream);
|
||||
expect(events.at(-1)?.type).toBe("done");
|
||||
|
||||
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
"Content-Type": "application/json",
|
||||
"X-OLLAMA-KEY": "provider-secret",
|
||||
@@ -841,7 +785,7 @@ describe("createOllamaStreamFn", () => {
|
||||
});
|
||||
|
||||
await collectStreamEvents(stream);
|
||||
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Authorization: "Bearer proxy-token",
|
||||
});
|
||||
@@ -877,7 +821,7 @@ describe("createOllamaStreamFn", () => {
|
||||
);
|
||||
|
||||
await collectStreamEvents(stream);
|
||||
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
|
||||
const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Authorization: "Bearer real-token",
|
||||
});
|
||||
@@ -886,13 +830,14 @@ describe("createOllamaStreamFn", () => {
|
||||
});
|
||||
|
||||
it("surfaces non-2xx HTTP response as status-prefixed error", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response("Service Unavailable", {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response("Service Unavailable", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
}),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
try {
|
||||
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
|
||||
const events = await collectStreamEvents(stream);
|
||||
@@ -905,7 +850,7 @@ describe("createOllamaStreamFn", () => {
|
||||
// extractLeadingHttpStatus can parse it for failover/retry logic.
|
||||
expect(errorEvent!.error.errorMessage).toMatch(/^503\b/);
|
||||
} finally {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1017,9 +962,8 @@ describe("createConfiguredOllamaStreamFn", () => {
|
||||
);
|
||||
|
||||
await collectStreamEvents(stream);
|
||||
const request = getGuardedFetchCall(fetchMock);
|
||||
expect(request.url).toBe("http://provider-host:11434/api/chat");
|
||||
const requestInit = request.init ?? {};
|
||||
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe("http://provider-host:11434/api/chat");
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
Authorization: "Bearer proxy-token",
|
||||
});
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
import { buildAssistantMessage, createOllamaStreamFn } from "./stream.js";
|
||||
|
||||
function makeOllamaResponse(params: {
|
||||
@@ -88,9 +79,7 @@ describe("buildAssistantMessage", () => {
|
||||
});
|
||||
|
||||
describe("createOllamaStreamFn thinking events", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function makeNdjsonBody(chunks: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
@@ -135,10 +124,11 @@ describe("createOllamaStreamFn thinking events", () => {
|
||||
];
|
||||
|
||||
const body = makeNdjsonBody(thinkingChunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body,
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
@@ -161,23 +151,28 @@ describe("createOllamaStreamFn thinking events", () => {
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(eventTypes).toContain("done");
|
||||
|
||||
// thinking_start comes before text_start
|
||||
const thinkingStartIndex = eventTypes.indexOf("thinking_start");
|
||||
const textStartIndex = eventTypes.indexOf("text_start");
|
||||
expect(thinkingStartIndex).toBeLessThan(textStartIndex);
|
||||
|
||||
// thinking_end comes before text_start
|
||||
const thinkingEndIndex = eventTypes.indexOf("thinking_end");
|
||||
expect(thinkingEndIndex).toBeLessThan(textStartIndex);
|
||||
|
||||
// Thinking deltas have correct content
|
||||
const thinkingDeltas = events.filter((e) => e.type === "thinking_delta");
|
||||
expect(thinkingDeltas).toHaveLength(2);
|
||||
expect(thinkingDeltas[0].delta).toBe("Step 1");
|
||||
expect(thinkingDeltas[1].delta).toBe(" and step 2");
|
||||
|
||||
// Content index: thinking at 0, text at 1
|
||||
const thinkingStart = events.find((e) => e.type === "thinking_start");
|
||||
expect(thinkingStart?.contentIndex).toBe(0);
|
||||
const textStart = events.find((e) => e.type === "text_start");
|
||||
expect(textStart?.contentIndex).toBe(1);
|
||||
|
||||
// Final message has thinking block
|
||||
const done = events.find((e) => e.type === "done") as { message?: { content: unknown[] } };
|
||||
const content = done?.message?.content ?? [];
|
||||
expect(content[0]).toMatchObject({ type: "thinking", thinking: "Step 1 and step 2" });
|
||||
@@ -204,10 +199,7 @@ describe("createOllamaStreamFn thinking events", () => {
|
||||
];
|
||||
|
||||
const body = makeNdjsonBody(chunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, body }));
|
||||
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
@@ -229,6 +221,7 @@ describe("createOllamaStreamFn thinking events", () => {
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(eventTypes).toContain("done");
|
||||
|
||||
// Text content index should be 0 (no thinking block)
|
||||
const textStart = events.find((e) => e.type === "text_start") as { contentIndex?: number };
|
||||
expect(textStart?.contentIndex).toBe(0);
|
||||
});
|
||||
|
||||
@@ -27,14 +27,12 @@ import {
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty, readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
|
||||
import {
|
||||
parseJsonObjectPreservingUnsafeIntegers,
|
||||
parseJsonPreservingUnsafeIntegers,
|
||||
} from "./ollama-json.js";
|
||||
import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js";
|
||||
|
||||
const log = createSubsystemLogger("ollama-stream");
|
||||
|
||||
@@ -222,11 +220,6 @@ export function createConfiguredOllamaCompatStreamWrapper(
|
||||
// Ollama compat wrapper now owns more than num_ctx injection.
|
||||
export const createConfiguredOllamaCompatNumCtxWrapper = createConfiguredOllamaCompatStreamWrapper;
|
||||
|
||||
function normalizeOllamaWireModelId(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
|
||||
}
|
||||
|
||||
export function buildOllamaChatRequest(params: {
|
||||
modelId: string;
|
||||
messages: OllamaChatMessage[];
|
||||
@@ -235,7 +228,7 @@ export function buildOllamaChatRequest(params: {
|
||||
stream?: boolean;
|
||||
}): OllamaChatRequest {
|
||||
return {
|
||||
model: normalizeOllamaWireModelId(params.modelId),
|
||||
model: params.modelId,
|
||||
messages: params.messages,
|
||||
stream: params.stream ?? true,
|
||||
...(params.tools && params.tools.length > 0 ? { tools: params.tools } : {}),
|
||||
@@ -613,7 +606,6 @@ export function createOllamaStreamFn(
|
||||
defaultHeaders?: Record<string, string>,
|
||||
): StreamFn {
|
||||
const chatUrl = resolveOllamaChatUrl(baseUrl);
|
||||
const ssrfPolicy = buildOllamaBaseUrlSsrFPolicy(chatUrl);
|
||||
|
||||
return (model, context, options) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
@@ -654,206 +646,200 @@ export function createOllamaStreamFn(
|
||||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
}
|
||||
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: chatUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "ollama-stream.chat",
|
||||
const response = await fetch(chatUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "unknown error");
|
||||
throw new Error(`${response.status} ${errorText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Ollama API returned empty response body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let accumulatedContent = "";
|
||||
let accumulatedThinking = "";
|
||||
const accumulatedToolCalls: OllamaToolCall[] = [];
|
||||
let finalResponse: OllamaChatResponse | undefined;
|
||||
const modelInfo = { api: model.api, provider: model.provider, id: model.id };
|
||||
let streamStarted = false;
|
||||
let thinkingStarted = false;
|
||||
let thinkingEnded = false;
|
||||
let textBlockStarted = false;
|
||||
let textBlockClosed = false;
|
||||
const textContentIndex = () => (thinkingStarted ? 1 : 0);
|
||||
|
||||
const buildCurrentContent = (): (TextContent | ThinkingContent | ToolCall)[] => {
|
||||
const parts: (TextContent | ThinkingContent | ToolCall)[] = [];
|
||||
if (accumulatedThinking) {
|
||||
parts.push({
|
||||
type: "thinking",
|
||||
thinking: accumulatedThinking,
|
||||
});
|
||||
}
|
||||
if (accumulatedContent) {
|
||||
parts.push({ type: "text", text: accumulatedContent });
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const closeThinkingBlock = () => {
|
||||
if (!thinkingStarted || thinkingEnded) {
|
||||
return;
|
||||
}
|
||||
thinkingEnded = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: 0,
|
||||
content: accumulatedThinking,
|
||||
partial,
|
||||
});
|
||||
};
|
||||
|
||||
const closeTextBlock = () => {
|
||||
if (!textBlockStarted || textBlockClosed) {
|
||||
return;
|
||||
}
|
||||
textBlockClosed = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: textContentIndex(),
|
||||
content: accumulatedContent,
|
||||
partial,
|
||||
});
|
||||
};
|
||||
|
||||
for await (const chunk of parseNdjsonStream(reader)) {
|
||||
const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning;
|
||||
if (thinkingDelta) {
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
const emptyPartial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "start", partial: emptyPartial });
|
||||
}
|
||||
if (!thinkingStarted) {
|
||||
thinkingStarted = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "thinking_start", contentIndex: 0, partial });
|
||||
}
|
||||
accumulatedThinking += thinkingDelta;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: 0,
|
||||
delta: thinkingDelta,
|
||||
partial,
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.message?.content) {
|
||||
const delta = chunk.message.content;
|
||||
if (thinkingStarted && !thinkingEnded) {
|
||||
closeThinkingBlock();
|
||||
}
|
||||
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
const emptyPartial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "start", partial: emptyPartial });
|
||||
}
|
||||
if (!textBlockStarted) {
|
||||
textBlockStarted = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "text_start", contentIndex: textContentIndex(), partial });
|
||||
}
|
||||
|
||||
accumulatedContent += delta;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: textContentIndex(),
|
||||
delta,
|
||||
partial,
|
||||
});
|
||||
}
|
||||
if (chunk.message?.tool_calls) {
|
||||
closeThinkingBlock();
|
||||
closeTextBlock();
|
||||
accumulatedToolCalls.push(...chunk.message.tool_calls);
|
||||
}
|
||||
if (chunk.done) {
|
||||
finalResponse = chunk;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error("Ollama API stream ended without a final response");
|
||||
}
|
||||
|
||||
finalResponse.message.content = accumulatedContent;
|
||||
if (accumulatedThinking) {
|
||||
finalResponse.message.thinking = accumulatedThinking;
|
||||
}
|
||||
if (accumulatedToolCalls.length > 0) {
|
||||
finalResponse.message.tool_calls = accumulatedToolCalls;
|
||||
}
|
||||
|
||||
const assistantMessage = buildAssistantMessage(finalResponse, modelInfo);
|
||||
closeThinkingBlock();
|
||||
closeTextBlock();
|
||||
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: assistantMessage.stopReason === "toolUse" ? "toolUse" : "stop",
|
||||
message: assistantMessage,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "unknown error");
|
||||
throw new Error(`${response.status} ${errorText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Ollama API returned empty response body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let accumulatedContent = "";
|
||||
let accumulatedThinking = "";
|
||||
const accumulatedToolCalls: OllamaToolCall[] = [];
|
||||
let finalResponse: OllamaChatResponse | undefined;
|
||||
const modelInfo = { api: model.api, provider: model.provider, id: model.id };
|
||||
let streamStarted = false;
|
||||
let thinkingStarted = false;
|
||||
let thinkingEnded = false;
|
||||
let textBlockStarted = false;
|
||||
let textBlockClosed = false;
|
||||
|
||||
// Content index tracking: thinking block (if present) is index 0,
|
||||
// text block follows at index 1 (or 0 when no thinking).
|
||||
const textContentIndex = () => (thinkingStarted ? 1 : 0);
|
||||
|
||||
const buildCurrentContent = (): (TextContent | ThinkingContent | ToolCall)[] => {
|
||||
const parts: (TextContent | ThinkingContent | ToolCall)[] = [];
|
||||
if (accumulatedThinking) {
|
||||
parts.push({
|
||||
type: "thinking",
|
||||
thinking: accumulatedThinking,
|
||||
});
|
||||
}
|
||||
if (accumulatedContent) {
|
||||
parts.push({ type: "text", text: accumulatedContent });
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const closeThinkingBlock = () => {
|
||||
if (!thinkingStarted || thinkingEnded) {
|
||||
return;
|
||||
}
|
||||
thinkingEnded = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: 0,
|
||||
content: accumulatedThinking,
|
||||
partial,
|
||||
});
|
||||
};
|
||||
|
||||
const closeTextBlock = () => {
|
||||
if (!textBlockStarted || textBlockClosed) {
|
||||
return;
|
||||
}
|
||||
textBlockClosed = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: textContentIndex(),
|
||||
content: accumulatedContent,
|
||||
partial,
|
||||
});
|
||||
};
|
||||
|
||||
for await (const chunk of parseNdjsonStream(reader)) {
|
||||
// Handle thinking/reasoning deltas from Ollama's native think mode.
|
||||
const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning;
|
||||
if (thinkingDelta) {
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
const emptyPartial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "start", partial: emptyPartial });
|
||||
}
|
||||
if (!thinkingStarted) {
|
||||
thinkingStarted = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "thinking_start", contentIndex: 0, partial });
|
||||
}
|
||||
accumulatedThinking += thinkingDelta;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: 0,
|
||||
delta: thinkingDelta,
|
||||
partial,
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.message?.content) {
|
||||
const delta = chunk.message.content;
|
||||
|
||||
// Transition from thinking to text: close the thinking block first.
|
||||
if (thinkingStarted && !thinkingEnded) {
|
||||
closeThinkingBlock();
|
||||
}
|
||||
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
const emptyPartial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "start", partial: emptyPartial });
|
||||
}
|
||||
if (!textBlockStarted) {
|
||||
textBlockStarted = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "text_start", contentIndex: textContentIndex(), partial });
|
||||
}
|
||||
|
||||
accumulatedContent += delta;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "text_delta", contentIndex: textContentIndex(), delta, partial });
|
||||
}
|
||||
if (chunk.message?.tool_calls) {
|
||||
closeThinkingBlock();
|
||||
closeTextBlock();
|
||||
accumulatedToolCalls.push(...chunk.message.tool_calls);
|
||||
}
|
||||
if (chunk.done) {
|
||||
finalResponse = chunk;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error("Ollama API stream ended without a final response");
|
||||
}
|
||||
|
||||
finalResponse.message.content = accumulatedContent;
|
||||
if (accumulatedThinking) {
|
||||
finalResponse.message.thinking = accumulatedThinking;
|
||||
}
|
||||
if (accumulatedToolCalls.length > 0) {
|
||||
finalResponse.message.tool_calls = accumulatedToolCalls;
|
||||
}
|
||||
|
||||
const assistantMessage = buildAssistantMessage(finalResponse, modelInfo);
|
||||
|
||||
// Close any open blocks before emitting the done event.
|
||||
closeThinkingBlock();
|
||||
closeTextBlock();
|
||||
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: assistantMessage.stopReason === "toolUse" ? "toolUse" : "stop",
|
||||
message: assistantMessage,
|
||||
});
|
||||
} catch (err) {
|
||||
stream.push({
|
||||
type: "error",
|
||||
|
||||
@@ -17,15 +17,11 @@ describe("openai base URL helpers", () => {
|
||||
it("recognizes Codex ChatGPT backend routes", () => {
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api")).toBe(true);
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/")).toBe(true);
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/v1")).toBe(true);
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/v1/")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-Codex backend routes", () => {
|
||||
expect(isOpenAICodexBaseUrl("https://api.openai.com/v1")).toBe(false);
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com")).toBe(false);
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/v2")).toBe(false);
|
||||
expect(isOpenAICodexBaseUrl("https://chatgpt.com/backend-api/codex")).toBe(false);
|
||||
expect(isOpenAICodexBaseUrl(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,5 +13,5 @@ export function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return /^https?:\/\/chatgpt\.com\/backend-api(?:\/v1)?\/?$/i.test(trimmed);
|
||||
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed);
|
||||
}
|
||||
|
||||
@@ -375,70 +375,4 @@ describe("openai codex provider", () => {
|
||||
name: "gpt-5.4",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults missing codex api metadata to openai-codex-responses", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const model = provider.normalizeResolvedModel?.({
|
||||
provider: "openai-codex",
|
||||
model: {
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes stale /backend-api/v1 codex metadata to the canonical base url", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const model = provider.normalizeResolvedModel?.({
|
||||
provider: "openai-codex",
|
||||
model: {
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api/v1",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes transport metadata for stale /backend-api/v1 codex routes", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
expect(
|
||||
provider.normalizeTransport?.({
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api/v1",
|
||||
} as never),
|
||||
).toEqual({
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,25 +94,6 @@ const OPENAI_CODEX_MODERN_MODEL_IDS = [
|
||||
OPENAI_CODEX_GPT_53_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_53_SPARK_MODEL_ID,
|
||||
] as const;
|
||||
|
||||
function normalizeCodexTransportFields(params: {
|
||||
api?: ProviderRuntimeModel["api"] | null;
|
||||
baseUrl?: string;
|
||||
}): {
|
||||
api?: ProviderRuntimeModel["api"];
|
||||
baseUrl?: string;
|
||||
} {
|
||||
const useCodexTransport =
|
||||
!params.baseUrl || isOpenAIApiBaseUrl(params.baseUrl) || isOpenAICodexBaseUrl(params.baseUrl);
|
||||
const api =
|
||||
useCodexTransport && (!params.api || params.api === "openai-responses")
|
||||
? "openai-codex-responses"
|
||||
: (params.api ?? undefined);
|
||||
const baseUrl =
|
||||
api === "openai-codex-responses" && useCodexTransport ? OPENAI_CODEX_BASE_URL : params.baseUrl;
|
||||
return { api, baseUrl };
|
||||
}
|
||||
|
||||
function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeModel {
|
||||
const lowerModelId = normalizeLowercaseStringOrEmpty(model.id);
|
||||
const canonicalModelId =
|
||||
@@ -121,12 +102,14 @@ function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeMo
|
||||
normalizeLowercaseStringOrEmpty(model.name) === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
|
||||
? OPENAI_CODEX_GPT_54_MODEL_ID
|
||||
: model.name;
|
||||
const normalizedTransport = normalizeCodexTransportFields({
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
});
|
||||
const api = normalizedTransport.api ?? model.api;
|
||||
const baseUrl = normalizedTransport.baseUrl ?? model.baseUrl;
|
||||
const useCodexTransport =
|
||||
!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl) || isOpenAICodexBaseUrl(model.baseUrl);
|
||||
const api =
|
||||
useCodexTransport && model.api === "openai-responses" ? "openai-codex-responses" : model.api;
|
||||
const baseUrl =
|
||||
api === "openai-codex-responses" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl))
|
||||
? OPENAI_CODEX_BASE_URL
|
||||
: model.baseUrl;
|
||||
if (
|
||||
api === model.api &&
|
||||
baseUrl === model.baseUrl &&
|
||||
@@ -352,16 +335,6 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
}
|
||||
return normalizeCodexTransport(ctx.model);
|
||||
},
|
||||
normalizeTransport: ({ provider, api, baseUrl }) => {
|
||||
if (normalizeProviderId(provider) !== PROVIDER_ID) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeCodexTransportFields({ api, baseUrl });
|
||||
if (normalized.api === api && normalized.baseUrl === baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
|
||||
fetchUsageSnapshot: async (ctx) =>
|
||||
await fetchCodexUsage(ctx.token, ctx.accountId, ctx.timeoutMs, ctx.fetchFn),
|
||||
|
||||
@@ -2,9 +2,6 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const { setRuntime: setQaChannelRuntime, getRuntime: getQaChannelRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "qa-channel",
|
||||
errorMessage: "QA channel runtime not initialized",
|
||||
});
|
||||
createPluginRuntimeStore<PluginRuntime>("QA channel runtime not initialized");
|
||||
|
||||
export { getQaChannelRuntime, setQaChannelRuntime };
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([
|
||||
"image-generation-core",
|
||||
"media-understanding-core",
|
||||
"speech-core",
|
||||
]);
|
||||
const QA_OPENAI_PLUGIN_ID = "openai";
|
||||
const QA_BUNDLED_PLUGIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
|
||||
function assertSafeQaBundledPluginId(pluginId: string) {
|
||||
if (!QA_BUNDLED_PLUGIN_ID_PATTERN.test(pluginId)) {
|
||||
throw new Error(`invalid QA bundled plugin id: ${pluginId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStableSemverFloor(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
major: Number.parseInt(match[1] ?? "", 10),
|
||||
minor: Number.parseInt(match[2] ?? "", 10),
|
||||
patch: Number.parseInt(match[3] ?? "", 10),
|
||||
label: `${match[1]}.${match[2]}.${match[3]}`,
|
||||
};
|
||||
}
|
||||
|
||||
function compareSemverFloors(
|
||||
left: ReturnType<typeof parseStableSemverFloor>,
|
||||
right: ReturnType<typeof parseStableSemverFloor>,
|
||||
) {
|
||||
if (!left && !right) {
|
||||
return 0;
|
||||
}
|
||||
if (!left) {
|
||||
return -1;
|
||||
}
|
||||
if (!right) {
|
||||
return 1;
|
||||
}
|
||||
if (left.major !== right.major) {
|
||||
return left.major - right.major;
|
||||
}
|
||||
if (left.minor !== right.minor) {
|
||||
return left.minor - right.minor;
|
||||
}
|
||||
return left.patch - right.patch;
|
||||
}
|
||||
|
||||
function isQaOpenAiResponsesProviderConfig(config: ModelProviderConfig) {
|
||||
return (
|
||||
config.api === "openai-responses" ||
|
||||
config.models.some((model) => model.api === "openai-responses")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveQaBundledPluginSourceDir(params: { repoRoot: string; pluginId: string }) {
|
||||
assertSafeQaBundledPluginId(params.pluginId);
|
||||
const candidates = [
|
||||
path.join(params.repoRoot, "dist", "extensions", params.pluginId),
|
||||
path.join(params.repoRoot, "dist-runtime", "extensions", params.pluginId),
|
||||
path.join(params.repoRoot, "extensions", params.pluginId),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveQaBundledPluginScanRoots(repoRoot: string) {
|
||||
return [
|
||||
path.join(repoRoot, "dist", "extensions"),
|
||||
path.join(repoRoot, "dist-runtime", "extensions"),
|
||||
path.join(repoRoot, "extensions"),
|
||||
].filter((candidate, index, all) => existsSync(candidate) && all.indexOf(candidate) === index);
|
||||
}
|
||||
|
||||
export async function resolveQaOwnerPluginIdsForProviderIds(params: {
|
||||
repoRoot: string;
|
||||
providerIds: readonly string[];
|
||||
providerConfigs?: Record<string, ModelProviderConfig>;
|
||||
}) {
|
||||
const providerIds = [
|
||||
...new Set(params.providerIds.map((providerId) => providerId.trim())),
|
||||
].filter((providerId) => providerId.length > 0);
|
||||
if (providerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const remainingProviderIds = new Set(providerIds);
|
||||
const ownerPluginIds = new Set<string>();
|
||||
const visitedPluginIds = new Set<string>();
|
||||
for (const sourceRoot of resolveQaBundledPluginScanRoots(params.repoRoot)) {
|
||||
for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const manifestPath = path.join(sourceRoot, entry.name, "openclaw.plugin.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as {
|
||||
id?: unknown;
|
||||
providers?: unknown;
|
||||
cliBackends?: unknown;
|
||||
};
|
||||
const pluginId = typeof manifest.id === "string" ? manifest.id.trim() : entry.name;
|
||||
if (!pluginId || visitedPluginIds.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
visitedPluginIds.add(pluginId);
|
||||
const ownedIds = new Set(
|
||||
[
|
||||
pluginId,
|
||||
...(Array.isArray(manifest.providers) ? manifest.providers : []),
|
||||
...(Array.isArray(manifest.cliBackends) ? manifest.cliBackends : []),
|
||||
].filter((ownedId): ownedId is string => typeof ownedId === "string"),
|
||||
);
|
||||
for (const providerId of providerIds) {
|
||||
if (!ownedIds.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
ownerPluginIds.add(pluginId);
|
||||
remainingProviderIds.delete(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const providerId of remainingProviderIds) {
|
||||
const providerConfig = params.providerConfigs?.[providerId];
|
||||
if (providerConfig && isQaOpenAiResponsesProviderConfig(providerConfig)) {
|
||||
ownerPluginIds.add(QA_OPENAI_PLUGIN_ID);
|
||||
continue;
|
||||
}
|
||||
ownerPluginIds.add(providerId);
|
||||
}
|
||||
return [...ownerPluginIds];
|
||||
}
|
||||
|
||||
function collectQaBundledPluginIds(params: {
|
||||
repoRoot: string;
|
||||
allowedPluginIds: readonly string[];
|
||||
}) {
|
||||
const pluginIds = new Set(
|
||||
params.allowedPluginIds.map((pluginId) => {
|
||||
assertSafeQaBundledPluginId(pluginId);
|
||||
return pluginId;
|
||||
}),
|
||||
);
|
||||
for (const pluginId of QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS) {
|
||||
if (
|
||||
resolveQaBundledPluginSourceDir({
|
||||
repoRoot: params.repoRoot,
|
||||
pluginId,
|
||||
})
|
||||
) {
|
||||
pluginIds.add(pluginId);
|
||||
}
|
||||
}
|
||||
return [...pluginIds];
|
||||
}
|
||||
|
||||
function resolveQaStagedBundledTreeName(repoRoot: string) {
|
||||
if (existsSync(path.join(repoRoot, "dist"))) {
|
||||
return "dist";
|
||||
}
|
||||
if (existsSync(path.join(repoRoot, "dist-runtime"))) {
|
||||
return "dist-runtime";
|
||||
}
|
||||
return "dist";
|
||||
}
|
||||
|
||||
function resolveQaBuiltBundledPluginTreeRoot(params: { repoRoot: string; sourceDir: string }) {
|
||||
const sourceDir = path.resolve(params.sourceDir);
|
||||
for (const treeName of ["dist", "dist-runtime"] as const) {
|
||||
const extensionsRoot = path.join(params.repoRoot, treeName, "extensions");
|
||||
const relativeSourceDir = path.relative(extensionsRoot, sourceDir);
|
||||
if (
|
||||
relativeSourceDir.length > 0 &&
|
||||
!relativeSourceDir.startsWith("..") &&
|
||||
!path.isAbsolute(relativeSourceDir)
|
||||
) {
|
||||
return path.join(params.repoRoot, treeName);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function symlinkQaStagedDirEntry(params: {
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
directory?: boolean;
|
||||
}) {
|
||||
await fs.symlink(
|
||||
params.sourcePath,
|
||||
params.targetPath,
|
||||
params.directory ? (process.platform === "win32" ? "junction" : "dir") : "file",
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveQaStagedDirEntryDirectory(params: {
|
||||
sourcePath: string;
|
||||
entry?: {
|
||||
isDirectory(): boolean;
|
||||
isSymbolicLink(): boolean;
|
||||
};
|
||||
}) {
|
||||
if (params.entry?.isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
if (params.entry?.isSymbolicLink()) {
|
||||
return (await fs.stat(params.sourcePath)).isDirectory();
|
||||
}
|
||||
if (params.entry) {
|
||||
return false;
|
||||
}
|
||||
return (await fs.lstat(params.sourcePath)).isDirectory();
|
||||
}
|
||||
|
||||
async function seedQaStagedNodeModules(params: { repoRoot: string; stagedRoot: string }) {
|
||||
const sourceNodeModulesDir = path.join(params.repoRoot, "node_modules");
|
||||
if (!existsSync(sourceNodeModulesDir)) {
|
||||
return;
|
||||
}
|
||||
const stagedNodeModulesDir = path.join(params.stagedRoot, "node_modules");
|
||||
await fs.mkdir(stagedNodeModulesDir, { recursive: true });
|
||||
for (const entry of await fs.readdir(sourceNodeModulesDir, { withFileTypes: true })) {
|
||||
if (entry.name === "openclaw") {
|
||||
continue;
|
||||
}
|
||||
await symlinkQaStagedDirEntry({
|
||||
sourcePath: path.join(sourceNodeModulesDir, entry.name),
|
||||
targetPath: path.join(stagedNodeModulesDir, entry.name),
|
||||
directory: await resolveQaStagedDirEntryDirectory({
|
||||
sourcePath: path.join(sourceNodeModulesDir, entry.name),
|
||||
entry,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectQaBuiltTreeRoots(params: {
|
||||
repoRoot: string;
|
||||
stagedPluginIds: readonly string[];
|
||||
stagedTreeName: string;
|
||||
}) {
|
||||
const treeRoots = new Set<string>();
|
||||
treeRoots.add(path.join(params.repoRoot, params.stagedTreeName));
|
||||
for (const pluginId of params.stagedPluginIds) {
|
||||
const sourceDir = resolveQaBundledPluginSourceDir({
|
||||
repoRoot: params.repoRoot,
|
||||
pluginId,
|
||||
});
|
||||
if (!sourceDir) {
|
||||
continue;
|
||||
}
|
||||
const builtTreeRoot = resolveQaBuiltBundledPluginTreeRoot({
|
||||
repoRoot: params.repoRoot,
|
||||
sourceDir,
|
||||
});
|
||||
if (builtTreeRoot) {
|
||||
treeRoots.add(builtTreeRoot);
|
||||
}
|
||||
}
|
||||
return [...treeRoots];
|
||||
}
|
||||
|
||||
async function seedQaStagedBuiltTreeRoots(params: {
|
||||
stagedTreeRoot: string;
|
||||
sourceTreeRoots: readonly string[];
|
||||
}) {
|
||||
for (const sourceTreeRoot of params.sourceTreeRoots) {
|
||||
if (!existsSync(sourceTreeRoot)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) {
|
||||
if (entry.name === "extensions") {
|
||||
continue;
|
||||
}
|
||||
const targetPath = path.join(params.stagedTreeRoot, entry.name);
|
||||
if (existsSync(targetPath)) {
|
||||
continue;
|
||||
}
|
||||
await symlinkQaStagedDirEntry({
|
||||
sourcePath: path.join(sourceTreeRoot, entry.name),
|
||||
targetPath,
|
||||
directory: await resolveQaStagedDirEntryDirectory({
|
||||
sourcePath: path.join(sourceTreeRoot, entry.name),
|
||||
entry,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveQaRuntimeHostVersion(params: {
|
||||
repoRoot: string;
|
||||
allowedPluginIds: readonly string[];
|
||||
}) {
|
||||
const rootPackageRaw = await fs.readFile(path.join(params.repoRoot, "package.json"), "utf8");
|
||||
const rootPackage = JSON.parse(rootPackageRaw) as { version?: string };
|
||||
let selected = parseStableSemverFloor(rootPackage.version);
|
||||
const stagedPluginIds = collectQaBundledPluginIds({
|
||||
repoRoot: params.repoRoot,
|
||||
allowedPluginIds: params.allowedPluginIds,
|
||||
});
|
||||
|
||||
for (const pluginId of stagedPluginIds) {
|
||||
const sourceDir = resolveQaBundledPluginSourceDir({
|
||||
repoRoot: params.repoRoot,
|
||||
pluginId,
|
||||
});
|
||||
if (!sourceDir) {
|
||||
continue;
|
||||
}
|
||||
const packagePath = path.join(sourceDir, "package.json");
|
||||
if (!existsSync(packagePath)) {
|
||||
continue;
|
||||
}
|
||||
const packageRaw = await fs.readFile(packagePath, "utf8");
|
||||
const packageJson = JSON.parse(packageRaw) as {
|
||||
openclaw?: {
|
||||
install?: {
|
||||
minHostVersion?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion);
|
||||
if (compareSemverFloors(candidate, selected) > 0) {
|
||||
selected = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.label;
|
||||
}
|
||||
|
||||
export async function createQaBundledPluginsDir(params: {
|
||||
repoRoot: string;
|
||||
tempRoot: string;
|
||||
allowedPluginIds: readonly string[];
|
||||
}) {
|
||||
const stagedPluginIds = collectQaBundledPluginIds({
|
||||
repoRoot: params.repoRoot,
|
||||
allowedPluginIds: params.allowedPluginIds,
|
||||
});
|
||||
const stagedRoot = path.join(
|
||||
params.repoRoot,
|
||||
".artifacts",
|
||||
"qa-runtime",
|
||||
path.basename(params.tempRoot),
|
||||
);
|
||||
await fs.rm(stagedRoot, { recursive: true, force: true });
|
||||
await fs.mkdir(stagedRoot, { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(params.repoRoot, "package.json"),
|
||||
path.join(stagedRoot, "package.json"),
|
||||
);
|
||||
await seedQaStagedNodeModules({
|
||||
repoRoot: params.repoRoot,
|
||||
stagedRoot,
|
||||
});
|
||||
const stagedOpenClawPackageDir = path.join(stagedRoot, "node_modules", "openclaw");
|
||||
await fs.mkdir(stagedOpenClawPackageDir, { recursive: true });
|
||||
await fs.copyFile(
|
||||
path.join(params.repoRoot, "package.json"),
|
||||
path.join(stagedOpenClawPackageDir, "package.json"),
|
||||
);
|
||||
const stagedTreeName = resolveQaStagedBundledTreeName(params.repoRoot);
|
||||
const stagedTreeRoot = path.join(stagedRoot, stagedTreeName);
|
||||
await fs.mkdir(stagedTreeRoot, { recursive: true });
|
||||
await seedQaStagedBuiltTreeRoots({
|
||||
stagedTreeRoot,
|
||||
sourceTreeRoots: collectQaBuiltTreeRoots({
|
||||
repoRoot: params.repoRoot,
|
||||
stagedPluginIds,
|
||||
stagedTreeName,
|
||||
}),
|
||||
});
|
||||
if (stagedTreeName === "dist-runtime" && !existsSync(path.join(stagedRoot, "dist"))) {
|
||||
const repoDistDir = path.join(params.repoRoot, "dist");
|
||||
const stagedDistTarget = existsSync(repoDistDir) ? repoDistDir : stagedTreeRoot;
|
||||
await symlinkQaStagedDirEntry({
|
||||
sourcePath: stagedDistTarget,
|
||||
targetPath: path.join(stagedRoot, "dist"),
|
||||
directory: true,
|
||||
});
|
||||
}
|
||||
const bundledPluginsDir = path.join(stagedTreeRoot, "extensions");
|
||||
await fs.mkdir(bundledPluginsDir, { recursive: true });
|
||||
for (const pluginId of stagedPluginIds) {
|
||||
const sourceDir = resolveQaBundledPluginSourceDir({
|
||||
repoRoot: params.repoRoot,
|
||||
pluginId,
|
||||
});
|
||||
if (!sourceDir) {
|
||||
throw new Error(`qa bundled plugin not found: ${pluginId}`);
|
||||
}
|
||||
await fs.cp(sourceDir, path.join(bundledPluginsDir, pluginId), { recursive: true });
|
||||
}
|
||||
await symlinkQaStagedDirEntry({
|
||||
sourcePath: path.join(stagedRoot, "dist"),
|
||||
targetPath: path.join(stagedOpenClawPackageDir, "dist"),
|
||||
directory: true,
|
||||
});
|
||||
return {
|
||||
bundledPluginsDir,
|
||||
stagedRoot,
|
||||
};
|
||||
}
|
||||
@@ -2,39 +2,19 @@ import { spawn } from "node:child_process";
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__testing,
|
||||
buildQaRuntimeEnv,
|
||||
resolveQaControlUiRoot,
|
||||
startQaGatewayChild,
|
||||
} from "./gateway-child.js";
|
||||
import { __testing, buildQaRuntimeEnv, resolveQaControlUiRoot } from "./gateway-child.js";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
const resolveQaNodeExecPathMock = vi.hoisted(() => vi.fn(async () => process.execPath));
|
||||
const qaTempPathState = vi.hoisted(() => ({
|
||||
preferredTmpDir: process.env.TMPDIR || "/tmp",
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/temp-path", () => ({
|
||||
resolvePreferredOpenClawTmpDir: () => qaTempPathState.preferredTmpDir,
|
||||
}));
|
||||
|
||||
vi.mock("./node-exec.js", () => ({
|
||||
resolveQaNodeExecPath: resolveQaNodeExecPathMock,
|
||||
}));
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
resolveQaNodeExecPathMock.mockReset();
|
||||
qaTempPathState.preferredTmpDir = process.env.TMPDIR || "/tmp";
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
@@ -56,28 +36,6 @@ function createParams(baseEnv?: NodeJS.ProcessEnv) {
|
||||
}
|
||||
|
||||
describe("buildQaRuntimeEnv", () => {
|
||||
it("cleans up temp QA gateway roots when node path resolution fails before startup", async () => {
|
||||
const tempParent = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-node-exec-fail-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(tempParent, { recursive: true, force: true });
|
||||
});
|
||||
qaTempPathState.preferredTmpDir = tempParent;
|
||||
resolveQaNodeExecPathMock.mockRejectedValueOnce(new Error("node missing"));
|
||||
|
||||
await expect(
|
||||
startQaGatewayChild({
|
||||
repoRoot: process.cwd(),
|
||||
transport: {
|
||||
requiredPluginIds: [],
|
||||
createGatewayConfig: () => ({}),
|
||||
},
|
||||
transportBaseUrl: "http://127.0.0.1:43123",
|
||||
}),
|
||||
).rejects.toThrow("node missing");
|
||||
|
||||
await expect(readdir(tempParent)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the slow-reply QA opt-out enabled under fast mode", () => {
|
||||
const env = buildQaRuntimeEnv({
|
||||
...createParams(),
|
||||
@@ -666,7 +624,7 @@ describe("resolveQaControlUiRoot", () => {
|
||||
});
|
||||
|
||||
describe("qa bundled plugin dir", () => {
|
||||
it("prefers a built bundled plugin when present", async () => {
|
||||
it("prefers the built bundled plugin tree when present", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-root-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
@@ -688,30 +646,10 @@ describe("qa bundled plugin dir", () => {
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true });
|
||||
await writeFile(path.join(repoRoot, "extensions", "qa-channel", "package.json"), "{}", "utf8");
|
||||
|
||||
expect(
|
||||
__testing.resolveQaBundledPluginSourceDir({
|
||||
repoRoot,
|
||||
pluginId: "qa-channel",
|
||||
}),
|
||||
).toBe(path.join(repoRoot, "dist", "extensions", "qa-channel"));
|
||||
});
|
||||
|
||||
it("falls back to the source bundled plugin when no built copy exists", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-root-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true });
|
||||
await writeFile(path.join(repoRoot, "extensions", "qa-channel", "package.json"), "{}", "utf8");
|
||||
|
||||
expect(
|
||||
__testing.resolveQaBundledPluginSourceDir({
|
||||
repoRoot,
|
||||
pluginId: "qa-channel",
|
||||
}),
|
||||
).toBe(path.join(repoRoot, "extensions", "qa-channel"));
|
||||
expect(__testing.resolveQaBundledPluginsSourceRoot(repoRoot)).toBe(
|
||||
path.join(repoRoot, "dist", "extensions"),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a scoped bundled plugin tree for allowed plugins plus always-allowed runtime facades", async () => {
|
||||
@@ -719,47 +657,10 @@ describe("qa bundled plugin dir", () => {
|
||||
cleanups.push(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
await writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "openclaw",
|
||||
type: "module",
|
||||
exports: {
|
||||
"./plugin-sdk/account-id": {
|
||||
default: "./dist/plugin-sdk/account-id.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "dist", "extensions", "qa-channel"), { recursive: true });
|
||||
await mkdir(path.join(repoRoot, "dist", "extensions", "memory-core"), { recursive: true });
|
||||
await mkdir(path.join(repoRoot, "dist", "extensions", "speech-core"), { recursive: true });
|
||||
await mkdir(path.join(repoRoot, "dist", "extensions", "unused-plugin"), { recursive: true });
|
||||
await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist", "plugin-sdk", "account-id.js"),
|
||||
"export const normalizeAccountId = (value) => value.toLowerCase();\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist", "extensions", "qa-channel", "package.json"),
|
||||
JSON.stringify({ name: "@openclaw/qa-channel", type: "module" }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist", "extensions", "qa-channel", "index.js"),
|
||||
[
|
||||
'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";',
|
||||
'export const accountId = normalizeAccountId("QA");',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(path.join(repoRoot, "dist", "shared-chunk-abc123.js"), "export {};\n", "utf8");
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-target-"));
|
||||
cleanups.push(async () => {
|
||||
@@ -790,20 +691,6 @@ describe("qa bundled plugin dir", () => {
|
||||
expect(stagedRoot).toBe(
|
||||
path.join(repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot)),
|
||||
);
|
||||
expect(stagedRoot).not.toBeNull();
|
||||
if (!stagedRoot) {
|
||||
throw new Error("expected staged runtime root");
|
||||
}
|
||||
await expect(readFile(path.join(stagedRoot, "package.json"), "utf8")).resolves.toContain(
|
||||
'"name": "openclaw"',
|
||||
);
|
||||
await expect(
|
||||
import(
|
||||
`${pathToFileURL(path.join(bundledPluginsDir, "qa-channel", "index.js")).href}?t=${Date.now()}`
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
accountId: "qa",
|
||||
});
|
||||
expect((await lstat(path.join(bundledPluginsDir, "qa-channel"))).isDirectory()).toBe(true);
|
||||
expect((await lstat(path.join(bundledPluginsDir, "memory-core"))).isDirectory()).toBe(true);
|
||||
expect((await lstat(path.join(bundledPluginsDir, "speech-core"))).isDirectory()).toBe(true);
|
||||
@@ -821,209 +708,6 @@ describe("qa bundled plugin dir", () => {
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("preserves dist-runtime-only root chunks when dist also exists", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-mixed-runtime-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
await writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "dist"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist", "shared-dist.js"),
|
||||
'export const dist = "dist";\n',
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "dist-runtime", "extensions", "runtime-only"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist-runtime", "runtime-chunk.js"),
|
||||
'export const marker = "runtime";\n',
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist-runtime", "extensions", "runtime-only", "package.json"),
|
||||
JSON.stringify({ name: "@openclaw/runtime-only", type: "module" }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist-runtime", "extensions", "runtime-only", "index.js"),
|
||||
['import { marker } from "../../runtime-chunk.js";', "export { marker };", ""].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-mixed-target-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const { bundledPluginsDir } = await __testing.createQaBundledPluginsDir({
|
||||
repoRoot,
|
||||
tempRoot,
|
||||
allowedPluginIds: ["runtime-only"],
|
||||
});
|
||||
|
||||
expect(bundledPluginsDir).toBe(
|
||||
path.join(
|
||||
repoRoot,
|
||||
".artifacts",
|
||||
"qa-runtime",
|
||||
path.basename(tempRoot),
|
||||
"dist",
|
||||
"extensions",
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
import(
|
||||
`${pathToFileURL(path.join(bundledPluginsDir, "runtime-only", "index.js")).href}?t=${Date.now()}`
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
marker: "runtime",
|
||||
});
|
||||
await expect(
|
||||
lstat(
|
||||
path.join(
|
||||
repoRoot,
|
||||
".artifacts",
|
||||
"qa-runtime",
|
||||
path.basename(tempRoot),
|
||||
"dist",
|
||||
"runtime-chunk.js",
|
||||
),
|
||||
),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects invalid bundled plugin ids before staging paths are built", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-invalid-id-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
await writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-invalid-target-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
await expect(
|
||||
__testing.createQaBundledPluginsDir({
|
||||
repoRoot,
|
||||
tempRoot,
|
||||
allowedPluginIds: ["../escape"],
|
||||
}),
|
||||
).rejects.toThrow("invalid QA bundled plugin id: ../escape");
|
||||
});
|
||||
|
||||
it("stages source-only bundled plugins into a repo-like runtime root with node_modules", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-stage-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(repoRoot, { recursive: true, force: true });
|
||||
});
|
||||
const fakeDepStoreRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-store-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(fakeDepStoreRoot, { recursive: true, force: true });
|
||||
});
|
||||
await writeFile(
|
||||
path.join(repoRoot, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "openclaw",
|
||||
type: "module",
|
||||
exports: {
|
||||
"./plugin-sdk/account-id": {
|
||||
default: "./dist/plugin-sdk/account-id.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "dist", "plugin-sdk", "account-id.js"),
|
||||
"export const normalizeAccountId = (value) => value.toLowerCase();\n",
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "extensions", "qa-channel", "package.json"),
|
||||
JSON.stringify({ name: "@openclaw/qa-channel", type: "module" }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "extensions", "qa-channel", "index.ts"),
|
||||
[
|
||||
'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";',
|
||||
'import { marker } from "fake-dep";',
|
||||
'export const accountId = `${normalizeAccountId("QA")}:${marker}`;',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const fakeDepPackageDir = path.join(fakeDepStoreRoot, "fake-dep");
|
||||
await mkdir(fakeDepPackageDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(fakeDepPackageDir, "package.json"),
|
||||
JSON.stringify({ name: "fake-dep", type: "module" }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(fakeDepPackageDir, "index.js"),
|
||||
'export const marker = "ok";\n',
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(path.join(repoRoot, "node_modules"), { recursive: true });
|
||||
await symlink(fakeDepPackageDir, path.join(repoRoot, "node_modules", "fake-dep"), "dir");
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-target-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const { bundledPluginsDir, stagedRoot } = await __testing.createQaBundledPluginsDir({
|
||||
repoRoot,
|
||||
tempRoot,
|
||||
allowedPluginIds: ["qa-channel"],
|
||||
});
|
||||
|
||||
expect(bundledPluginsDir).toBe(
|
||||
path.join(
|
||||
repoRoot,
|
||||
".artifacts",
|
||||
"qa-runtime",
|
||||
path.basename(tempRoot),
|
||||
"dist",
|
||||
"extensions",
|
||||
),
|
||||
);
|
||||
if (!stagedRoot) {
|
||||
throw new Error("expected staged runtime root");
|
||||
}
|
||||
await expect(
|
||||
import(
|
||||
`${pathToFileURL(path.join(bundledPluginsDir, "qa-channel", "index.ts")).href}?t=${Date.now()}`
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
accountId: "qa:ok",
|
||||
});
|
||||
await expect(
|
||||
lstat(path.join(stagedRoot, "node_modules", "fake-dep")).then((stats) =>
|
||||
stats.isSymbolicLink(),
|
||||
),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
readFile(path.join(stagedRoot, "node_modules", "fake-dep", "index.js"), "utf8"),
|
||||
).resolves.toContain('marker = "ok"');
|
||||
});
|
||||
|
||||
it("maps cli backend provider ids to their owning bundled plugin ids", async () => {
|
||||
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-plugin-owner-"));
|
||||
cleanups.push(async () => {
|
||||
@@ -1171,6 +855,7 @@ describe("qa bundled plugin dir", () => {
|
||||
await expect(
|
||||
__testing.resolveQaRuntimeHostVersion({
|
||||
repoRoot,
|
||||
bundledPluginsSourceRoot: bundledRoot,
|
||||
allowedPluginIds: ["memory-core", "qa-channel"],
|
||||
}),
|
||||
).resolves.toBe("2026.4.8");
|
||||
@@ -1203,6 +888,7 @@ describe("qa bundled plugin dir", () => {
|
||||
await expect(
|
||||
__testing.resolveQaRuntimeHostVersion({
|
||||
repoRoot,
|
||||
bundledPluginsSourceRoot: bundledRoot,
|
||||
allowedPluginIds: ["qa-channel"],
|
||||
}),
|
||||
).resolves.toBe("2026.4.9");
|
||||
|
||||
@@ -16,17 +16,10 @@ import {
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import {
|
||||
createQaBundledPluginsDir,
|
||||
resolveQaBundledPluginSourceDir,
|
||||
resolveQaOwnerPluginIdsForProviderIds,
|
||||
resolveQaRuntimeHostVersion,
|
||||
} from "./bundled-plugin-staging.js";
|
||||
import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js";
|
||||
import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js";
|
||||
import { startQaGatewayRpcClient } from "./gateway-rpc-client.js";
|
||||
import { splitQaModelRef } from "./model-selection.js";
|
||||
import { resolveQaNodeExecPath } from "./node-exec.js";
|
||||
import { seedQaAgentWorkspace } from "./qa-agent-workspace.js";
|
||||
import { buildQaGatewayConfig, type QaThinkingLevel } from "./qa-gateway-config.js";
|
||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
@@ -85,9 +78,18 @@ const QA_MOCK_BLOCKED_ENV_KEY_PATTERNS = Object.freeze([
|
||||
|
||||
const QA_LIVE_PROVIDER_CONFIG_PATH_ENV = "OPENCLAW_QA_LIVE_PROVIDER_CONFIG_PATH";
|
||||
const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN";
|
||||
// Keep this in sync with the facade runtime's always-allowed bundled surfaces.
|
||||
// QA child staging must include these runtime helpers even when they are not in
|
||||
// cfg.plugins.allow, otherwise lazy facade loads can fail inside the child.
|
||||
const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([
|
||||
"image-generation-core",
|
||||
"media-understanding-core",
|
||||
"speech-core",
|
||||
]);
|
||||
const QA_LIVE_SETUP_TOKEN_VALUE_ENV = "OPENCLAW_LIVE_SETUP_TOKEN_VALUE";
|
||||
const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE";
|
||||
const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ID = "anthropic:qa-setup-token";
|
||||
const QA_OPENAI_PLUGIN_ID = "openai";
|
||||
const QA_LIVE_CLI_BACKEND_PRESERVE_ENV = "OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV";
|
||||
const QA_LIVE_CLI_BACKEND_AUTH_MODE_ENV = "OPENCLAW_LIVE_CLI_BACKEND_AUTH_MODE";
|
||||
export type QaCliBackendAuthMode = "auto" | "api-key" | "subscription";
|
||||
@@ -524,7 +526,7 @@ export const __testing = {
|
||||
stageQaMockAuthProfiles,
|
||||
resolveQaLiveCliAuthEnv,
|
||||
resolveQaOwnerPluginIdsForProviderIds,
|
||||
resolveQaBundledPluginSourceDir,
|
||||
resolveQaBundledPluginsSourceRoot,
|
||||
resolveQaRuntimeHostVersion,
|
||||
createQaBundledPluginsDir,
|
||||
stopQaGatewayChildProcessTree,
|
||||
@@ -578,6 +580,77 @@ async function stopQaGatewayChildProcessTree(
|
||||
await waitForQaGatewayChildExit(child, opts?.forceTimeoutMs ?? 2_000);
|
||||
}
|
||||
|
||||
function resolveQaBundledPluginsSourceRoot(repoRoot: string) {
|
||||
const candidates = [
|
||||
path.join(repoRoot, "dist", "extensions"),
|
||||
path.join(repoRoot, "dist-runtime", "extensions"),
|
||||
path.join(repoRoot, "extensions"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new Error("failed to resolve qa bundled plugins source root");
|
||||
}
|
||||
|
||||
async function resolveQaOwnerPluginIdsForProviderIds(params: {
|
||||
repoRoot: string;
|
||||
providerIds: readonly string[];
|
||||
providerConfigs?: Record<string, ModelProviderConfig>;
|
||||
}) {
|
||||
const providerIds = [
|
||||
...new Set(params.providerIds.map((providerId) => providerId.trim())),
|
||||
].filter((providerId) => providerId.length > 0);
|
||||
if (providerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const remainingProviderIds = new Set(providerIds);
|
||||
const ownerPluginIds = new Set<string>();
|
||||
const sourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot);
|
||||
for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const manifestPath = path.join(sourceRoot, entry.name, "openclaw.plugin.json");
|
||||
if (!existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as {
|
||||
id?: unknown;
|
||||
providers?: unknown;
|
||||
cliBackends?: unknown;
|
||||
};
|
||||
const pluginId = typeof manifest.id === "string" ? manifest.id.trim() : entry.name;
|
||||
if (!pluginId) {
|
||||
continue;
|
||||
}
|
||||
const ownedIds = new Set(
|
||||
[
|
||||
pluginId,
|
||||
...(Array.isArray(manifest.providers) ? manifest.providers : []),
|
||||
...(Array.isArray(manifest.cliBackends) ? manifest.cliBackends : []),
|
||||
].filter((ownedId): ownedId is string => typeof ownedId === "string"),
|
||||
);
|
||||
for (const providerId of providerIds) {
|
||||
if (!ownedIds.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
ownerPluginIds.add(pluginId);
|
||||
remainingProviderIds.delete(providerId);
|
||||
}
|
||||
}
|
||||
for (const providerId of remainingProviderIds) {
|
||||
const providerConfig = params.providerConfigs?.[providerId];
|
||||
if (providerConfig && isQaOpenAiResponsesProviderConfig(providerConfig)) {
|
||||
ownerPluginIds.add(QA_OPENAI_PLUGIN_ID);
|
||||
continue;
|
||||
}
|
||||
ownerPluginIds.add(providerId);
|
||||
}
|
||||
return [...ownerPluginIds];
|
||||
}
|
||||
|
||||
function resolveQaUserPath(value: string, env: NodeJS.ProcessEnv = process.env) {
|
||||
if (value === "~") {
|
||||
return env.HOME ?? os.homedir();
|
||||
@@ -604,6 +677,13 @@ function isQaModelProviderConfig(value: unknown): value is ModelProviderConfig {
|
||||
return isRecord(value) && typeof value.baseUrl === "string" && Array.isArray(value.models);
|
||||
}
|
||||
|
||||
function isQaOpenAiResponsesProviderConfig(config: ModelProviderConfig) {
|
||||
return (
|
||||
config.api === "openai-responses" ||
|
||||
config.models.some((model) => model.api === "openai-responses")
|
||||
);
|
||||
}
|
||||
|
||||
async function readQaLiveProviderConfigOverrides(params: {
|
||||
providerIds: readonly string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -647,6 +727,157 @@ async function readQaLiveProviderConfigOverrides(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function parseStableSemverFloor(value: string | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
major: Number.parseInt(match[1] ?? "", 10),
|
||||
minor: Number.parseInt(match[2] ?? "", 10),
|
||||
patch: Number.parseInt(match[3] ?? "", 10),
|
||||
label: `${match[1]}.${match[2]}.${match[3]}`,
|
||||
};
|
||||
}
|
||||
|
||||
function compareSemverFloors(
|
||||
left: ReturnType<typeof parseStableSemverFloor>,
|
||||
right: ReturnType<typeof parseStableSemverFloor>,
|
||||
) {
|
||||
if (!left && !right) {
|
||||
return 0;
|
||||
}
|
||||
if (!left) {
|
||||
return -1;
|
||||
}
|
||||
if (!right) {
|
||||
return 1;
|
||||
}
|
||||
if (left.major !== right.major) {
|
||||
return left.major - right.major;
|
||||
}
|
||||
if (left.minor !== right.minor) {
|
||||
return left.minor - right.minor;
|
||||
}
|
||||
return left.patch - right.patch;
|
||||
}
|
||||
|
||||
async function resolveQaRuntimeHostVersion(params: {
|
||||
repoRoot: string;
|
||||
bundledPluginsSourceRoot: string;
|
||||
allowedPluginIds: readonly string[];
|
||||
}) {
|
||||
const rootPackageRaw = await fs.readFile(path.join(params.repoRoot, "package.json"), "utf8");
|
||||
const rootPackage = JSON.parse(rootPackageRaw) as { version?: string };
|
||||
let selected = parseStableSemverFloor(rootPackage.version);
|
||||
const stagedPluginIds = collectQaBundledPluginIds({
|
||||
sourceRoot: params.bundledPluginsSourceRoot,
|
||||
allowedPluginIds: params.allowedPluginIds,
|
||||
});
|
||||
|
||||
for (const pluginId of stagedPluginIds) {
|
||||
const packagePath = path.join(params.bundledPluginsSourceRoot, pluginId, "package.json");
|
||||
if (!existsSync(packagePath)) {
|
||||
continue;
|
||||
}
|
||||
const packageRaw = await fs.readFile(packagePath, "utf8");
|
||||
const packageJson = JSON.parse(packageRaw) as {
|
||||
openclaw?: {
|
||||
install?: {
|
||||
minHostVersion?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion);
|
||||
if (compareSemverFloors(candidate, selected) > 0) {
|
||||
selected = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return selected?.label;
|
||||
}
|
||||
|
||||
function collectQaBundledPluginIds(params: {
|
||||
sourceRoot: string;
|
||||
allowedPluginIds: readonly string[];
|
||||
}) {
|
||||
const pluginIds = new Set(params.allowedPluginIds);
|
||||
for (const pluginId of QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS) {
|
||||
if (existsSync(path.join(params.sourceRoot, pluginId))) {
|
||||
pluginIds.add(pluginId);
|
||||
}
|
||||
}
|
||||
return [...pluginIds];
|
||||
}
|
||||
|
||||
async function createQaBundledPluginsDir(params: {
|
||||
repoRoot: string;
|
||||
tempRoot: string;
|
||||
allowedPluginIds: readonly string[];
|
||||
}) {
|
||||
const sourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot);
|
||||
const stagedPluginIds = collectQaBundledPluginIds({
|
||||
sourceRoot,
|
||||
allowedPluginIds: params.allowedPluginIds,
|
||||
});
|
||||
const sourceTreeRoot = path.dirname(sourceRoot);
|
||||
if (
|
||||
sourceTreeRoot === path.join(params.repoRoot, "dist") ||
|
||||
sourceTreeRoot === path.join(params.repoRoot, "dist-runtime")
|
||||
) {
|
||||
const stagedRoot = path.join(
|
||||
params.repoRoot,
|
||||
".artifacts",
|
||||
"qa-runtime",
|
||||
path.basename(params.tempRoot),
|
||||
);
|
||||
await fs.rm(stagedRoot, { recursive: true, force: true });
|
||||
await fs.mkdir(stagedRoot, { recursive: true });
|
||||
const stagedTreeRoot = path.join(stagedRoot, path.basename(sourceTreeRoot));
|
||||
await fs.mkdir(stagedTreeRoot, { recursive: true });
|
||||
for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) {
|
||||
const sourcePath = path.join(sourceTreeRoot, entry.name);
|
||||
const targetPath = path.join(stagedTreeRoot, entry.name);
|
||||
if (entry.name === "extensions") {
|
||||
await fs.mkdir(targetPath, { recursive: true });
|
||||
for (const pluginId of stagedPluginIds) {
|
||||
const sourceDir = path.join(sourceRoot, pluginId);
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`);
|
||||
}
|
||||
await fs.cp(sourceDir, path.join(targetPath, pluginId), { recursive: true });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await fs.symlink(sourcePath, targetPath);
|
||||
}
|
||||
const stagedExtensionsDir = path.join(stagedTreeRoot, "extensions");
|
||||
return {
|
||||
bundledPluginsDir: stagedExtensionsDir,
|
||||
stagedRoot,
|
||||
};
|
||||
}
|
||||
|
||||
const bundledPluginsDir = path.join(params.tempRoot, "bundled-plugins");
|
||||
await fs.mkdir(bundledPluginsDir, { recursive: true });
|
||||
for (const pluginId of stagedPluginIds) {
|
||||
const sourceDir = path.join(sourceRoot, pluginId);
|
||||
if (!existsSync(sourceDir)) {
|
||||
throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`);
|
||||
}
|
||||
// Plugin discovery walks real directories; copying avoids symlink-only
|
||||
// trees being skipped by Dirent-based scans in the child runtime.
|
||||
await fs.cp(sourceDir, path.join(bundledPluginsDir, pluginId), { recursive: true });
|
||||
}
|
||||
return {
|
||||
bundledPluginsDir,
|
||||
stagedRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForGatewayReady(params: {
|
||||
baseUrl: string;
|
||||
logs: () => string;
|
||||
@@ -822,7 +1053,6 @@ export async function startQaGatewayChild(params: {
|
||||
let env: NodeJS.ProcessEnv | null = null;
|
||||
|
||||
try {
|
||||
const nodeExecPath = await resolveQaNodeExecPath();
|
||||
for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) {
|
||||
gatewayPort = await getFreePort();
|
||||
baseUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||
@@ -838,6 +1068,7 @@ export async function startQaGatewayChild(params: {
|
||||
);
|
||||
},
|
||||
);
|
||||
const bundledPluginsSourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot);
|
||||
const { bundledPluginsDir, stagedRoot } = await createQaBundledPluginsDir({
|
||||
repoRoot: params.repoRoot,
|
||||
tempRoot,
|
||||
@@ -846,6 +1077,7 @@ export async function startQaGatewayChild(params: {
|
||||
stagedBundledPluginsRoot = stagedRoot;
|
||||
const runtimeHostVersion = await resolveQaRuntimeHostVersion({
|
||||
repoRoot: params.repoRoot,
|
||||
bundledPluginsSourceRoot,
|
||||
allowedPluginIds,
|
||||
});
|
||||
env = buildQaRuntimeEnv({
|
||||
@@ -873,7 +1105,7 @@ export async function startQaGatewayChild(params: {
|
||||
}
|
||||
|
||||
const attemptChild = spawn(
|
||||
nodeExecPath,
|
||||
process.execPath,
|
||||
[
|
||||
distEntryPath,
|
||||
"gateway",
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mapCaptureEventForQa, probeTcpReachability } from "./lab-server-capture.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
describe("qa-lab server capture helpers", () => {
|
||||
it("maps capture rows into QA-friendly fields", () => {
|
||||
expect(
|
||||
mapCaptureEventForQa({
|
||||
flowId: "flow-1",
|
||||
dataText: '{"hello":"world"}',
|
||||
metaJson: JSON.stringify({
|
||||
provider: "openai",
|
||||
api: "responses",
|
||||
model: "gpt-5.4",
|
||||
captureOrigin: "shared-fetch",
|
||||
}),
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
flowId: "flow-1",
|
||||
payloadPreview: '{"hello":"world"}',
|
||||
provider: "openai",
|
||||
api: "responses",
|
||||
model: "gpt-5.4",
|
||||
captureOrigin: "shared-fetch",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("probes tcp reachability for reachable and unreachable targets", async () => {
|
||||
const server = createServer((_req, res) => {
|
||||
res.writeHead(200);
|
||||
res.end("ok");
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
);
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected tcp probe address");
|
||||
}
|
||||
|
||||
await expect(probeTcpReachability(`http://127.0.0.1:${address.port}`)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
await expect(probeTcpReachability("http://127.0.0.1:9", 50)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
import net from "node:net";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
||||
const CAPTURE_QUERY_PRESETS = new Set([
|
||||
"double-sends",
|
||||
"retry-storms",
|
||||
"cache-busting",
|
||||
"ws-duplicate-frames",
|
||||
"missing-ack",
|
||||
"error-bursts",
|
||||
]);
|
||||
|
||||
export type QaStartupProbeStatus = {
|
||||
label: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function isCaptureQueryPreset(
|
||||
value: string,
|
||||
): value is Parameters<
|
||||
ReturnType<
|
||||
typeof import("openclaw/plugin-sdk/proxy-capture").getDebugProxyCaptureStore
|
||||
>["queryPreset"]
|
||||
>[0] {
|
||||
return CAPTURE_QUERY_PRESETS.has(value);
|
||||
}
|
||||
|
||||
function parseCaptureMeta(metaJson: unknown): Record<string, unknown> | null {
|
||||
if (typeof metaJson !== "string" || metaJson.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(metaJson) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCaptureMetaString(
|
||||
meta: Record<string, unknown> | null,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = meta?.[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function mapCaptureEventForQa(row: Record<string, unknown>) {
|
||||
const meta = parseCaptureMeta(row.metaJson);
|
||||
return {
|
||||
...row,
|
||||
payloadPreview: typeof row.dataText === "string" ? row.dataText : undefined,
|
||||
provider: readCaptureMetaString(meta, "provider"),
|
||||
api: readCaptureMetaString(meta, "api"),
|
||||
model: readCaptureMetaString(meta, "model"),
|
||||
captureOrigin: readCaptureMetaString(meta, "captureOrigin"),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPortForProtocol(protocol: string): number {
|
||||
if (protocol === "https:") {
|
||||
return 443;
|
||||
}
|
||||
if (protocol === "http:") {
|
||||
return 80;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function probeTcpReachability(
|
||||
rawUrl: string,
|
||||
timeoutMs = 700,
|
||||
): Promise<QaStartupProbeStatus> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return {
|
||||
label: rawUrl,
|
||||
url: rawUrl,
|
||||
ok: false,
|
||||
error: "invalid url",
|
||||
};
|
||||
}
|
||||
const host = parsed.hostname;
|
||||
const port = parsed.port ? Number(parsed.port) : defaultPortForProtocol(parsed.protocol);
|
||||
if (!host || !Number.isFinite(port) || port <= 0) {
|
||||
return {
|
||||
label: parsed.origin,
|
||||
url: parsed.toString(),
|
||||
ok: false,
|
||||
error: "missing host or port",
|
||||
};
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const onError = (error: Error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => {
|
||||
socket.destroy(new Error("timeout"));
|
||||
});
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", onError);
|
||||
socket.once("timeout", () => onError(new Error("timeout")));
|
||||
});
|
||||
return {
|
||||
label: parsed.host,
|
||||
url: parsed.toString(),
|
||||
ok: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
label: parsed.host,
|
||||
url: parsed.toString(),
|
||||
ok: false,
|
||||
error: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectContentType,
|
||||
missingUiHtml,
|
||||
resolveUiAssetVersion,
|
||||
tryResolveUiAsset,
|
||||
} from "./lab-server-ui.js";
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups.pop()?.();
|
||||
}
|
||||
});
|
||||
|
||||
describe("qa-lab server ui helpers", () => {
|
||||
it("detects basic UI asset content types", () => {
|
||||
expect(detectContentType("index.html")).toBe("text/html; charset=utf-8");
|
||||
expect(detectContentType("styles.css")).toBe("text/css; charset=utf-8");
|
||||
expect(detectContentType("main.js")).toBe("text/javascript; charset=utf-8");
|
||||
expect(detectContentType("icon.svg")).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
it("renders the missing-ui placeholder html", () => {
|
||||
expect(missingUiHtml()).toContain("QA Lab UI not built");
|
||||
expect(missingUiHtml()).toContain("pnpm qa:lab:build");
|
||||
});
|
||||
|
||||
it("hashes built UI assets and changes when bundle contents change", async () => {
|
||||
const uiDistDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-dist-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(uiDistDir, { recursive: true, force: true });
|
||||
});
|
||||
await writeFile(
|
||||
path.join(uiDistDir, "index.html"),
|
||||
"<!doctype html><html><head><title>QA Lab</title></head><body><div id='app'></div></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const version1 = resolveUiAssetVersion(uiDistDir);
|
||||
expect(version1).toMatch(/^[0-9a-f]{12}$/);
|
||||
|
||||
await writeFile(
|
||||
path.join(uiDistDir, "index.html"),
|
||||
"<!doctype html><html><head><title>QA Lab Updated</title></head><body><div id='app'></div></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const version2 = resolveUiAssetVersion(uiDistDir);
|
||||
expect(version2).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(version2).not.toBe(version1);
|
||||
});
|
||||
|
||||
it("never resolves sibling files outside the UI dist root", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-boundary-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
const uiDistDir = path.join(rootDir, "dist");
|
||||
const siblingDir = path.join(rootDir, "dist-other");
|
||||
await mkdir(uiDistDir, { recursive: true });
|
||||
await mkdir(siblingDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(uiDistDir, "index.html"),
|
||||
"<!doctype html><html><body>bundle-root</body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(path.join(siblingDir, "secret.txt"), "sibling-secret", "utf8");
|
||||
|
||||
expect(tryResolveUiAsset("/", uiDistDir, rootDir)).toBe(path.join(uiDistDir, "index.html"));
|
||||
expect(tryResolveUiAsset("/../dist-other/secret.txt", uiDistDir, rootDir)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed percent-encoded UI asset paths", async () => {
|
||||
const uiDistDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-malformed-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(uiDistDir, { recursive: true, force: true });
|
||||
});
|
||||
await writeFile(
|
||||
path.join(uiDistDir, "index.html"),
|
||||
"<!doctype html><html><body>bundle-root</body></html>",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(tryResolveUiAsset("/%E0%A4", uiDistDir, uiDistDir)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,288 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { request as httpRequest, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
import tls from "node:tls";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { writeError } from "./bus-server.js";
|
||||
|
||||
export function detectContentType(filePath: string): string {
|
||||
if (filePath.endsWith(".css")) {
|
||||
return "text/css; charset=utf-8";
|
||||
}
|
||||
if (filePath.endsWith(".js")) {
|
||||
return "text/javascript; charset=utf-8";
|
||||
}
|
||||
if (filePath.endsWith(".json")) {
|
||||
return "application/json; charset=utf-8";
|
||||
}
|
||||
if (filePath.endsWith(".svg")) {
|
||||
return "image/svg+xml";
|
||||
}
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
|
||||
export function missingUiHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>QA Lab UI Missing</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; background: #0f1115; color: #f5f7fb; margin: 0; display: grid; place-items: center; min-height: 100vh; }
|
||||
main { max-width: 42rem; padding: 2rem; background: #171b22; border: 1px solid #283140; border-radius: 18px; box-shadow: 0 30px 80px rgba(0,0,0,.35); }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #9ee8d8; }
|
||||
h1 { margin-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>QA Lab UI not built</h1>
|
||||
<p>Build the private debugger bundle, then reload this page.</p>
|
||||
<p><code>pnpm qa:lab:build</code></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function resolveUiDistDir(overrideDir?: string | null, repoRoot = process.cwd()) {
|
||||
if (overrideDir?.trim()) {
|
||||
return overrideDir;
|
||||
}
|
||||
const candidates = [
|
||||
path.resolve(repoRoot, "extensions/qa-lab/web/dist"),
|
||||
path.resolve(repoRoot, "dist/extensions/qa-lab/web/dist"),
|
||||
fileURLToPath(new URL("../web/dist", import.meta.url)),
|
||||
];
|
||||
return (
|
||||
candidates.find((candidate) => {
|
||||
if (!fs.existsSync(candidate)) {
|
||||
return false;
|
||||
}
|
||||
const indexPath = path.join(candidate, "index.html");
|
||||
return fs.existsSync(indexPath) && fs.statSync(indexPath).isFile();
|
||||
}) ?? candidates[0]
|
||||
);
|
||||
}
|
||||
|
||||
function listUiAssetFiles(rootDir: string, currentDir = rootDir): string[] {
|
||||
const entries = fs
|
||||
.readdirSync(currentDir, { withFileTypes: true })
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listUiAssetFiles(rootDir, resolved));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
files.push(path.relative(rootDir, resolved));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function resolveUiAssetVersion(overrideDir?: string | null): string | null {
|
||||
try {
|
||||
const distDir = resolveUiDistDir(overrideDir);
|
||||
const indexPath = path.join(distDir, "index.html");
|
||||
if (!fs.existsSync(indexPath) || !fs.statSync(indexPath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
const hash = createHash("sha1");
|
||||
for (const relativeFile of listUiAssetFiles(distDir)) {
|
||||
hash.update(relativeFile);
|
||||
hash.update("\0");
|
||||
hash.update(fs.readFileSync(path.join(distDir, relativeFile)));
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAdvertisedBaseUrl(params: {
|
||||
bindHost?: string;
|
||||
bindPort: number;
|
||||
advertiseHost?: string;
|
||||
advertisePort?: number;
|
||||
}) {
|
||||
const advertisedHost =
|
||||
params.advertiseHost?.trim() ||
|
||||
(params.bindHost && params.bindHost !== "0.0.0.0" ? params.bindHost : "127.0.0.1");
|
||||
const advertisedPort =
|
||||
typeof params.advertisePort === "number" && Number.isFinite(params.advertisePort)
|
||||
? params.advertisePort
|
||||
: params.bindPort;
|
||||
return `http://${advertisedHost}:${advertisedPort}`;
|
||||
}
|
||||
|
||||
export function isControlUiProxyPath(pathname: string) {
|
||||
return pathname === "/control-ui" || pathname.startsWith("/control-ui/");
|
||||
}
|
||||
|
||||
function rewriteControlUiProxyPath(pathname: string, search: string) {
|
||||
const stripped = pathname === "/control-ui" ? "/" : pathname.slice("/control-ui".length) || "/";
|
||||
return `${stripped}${search}`;
|
||||
}
|
||||
|
||||
function rewriteEmbeddedControlUiHeaders(
|
||||
headers: IncomingMessage["headers"],
|
||||
): Record<string, string | string[] | number | undefined> {
|
||||
const rewritten: Record<string, string | string[] | number | undefined> = { ...headers };
|
||||
delete rewritten["x-frame-options"];
|
||||
|
||||
const csp = headers["content-security-policy"];
|
||||
if (typeof csp === "string") {
|
||||
rewritten["content-security-policy"] = csp.includes("frame-ancestors")
|
||||
? csp.replace(/frame-ancestors\s+[^;]+/i, "frame-ancestors 'self'")
|
||||
: `${csp}; frame-ancestors 'self'`;
|
||||
}
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
export async function proxyHttpRequest(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
target: URL;
|
||||
pathname: string;
|
||||
search: string;
|
||||
}) {
|
||||
const client = params.target.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const upstreamReq = client(
|
||||
{
|
||||
protocol: params.target.protocol,
|
||||
hostname: params.target.hostname,
|
||||
port: params.target.port || (params.target.protocol === "https:" ? 443 : 80),
|
||||
method: params.req.method,
|
||||
path: rewriteControlUiProxyPath(params.pathname, params.search),
|
||||
headers: {
|
||||
...params.req.headers,
|
||||
host: params.target.host,
|
||||
},
|
||||
},
|
||||
(upstreamRes) => {
|
||||
params.res.writeHead(
|
||||
upstreamRes.statusCode ?? 502,
|
||||
rewriteEmbeddedControlUiHeaders(upstreamRes.headers),
|
||||
);
|
||||
upstreamRes.pipe(params.res);
|
||||
},
|
||||
);
|
||||
|
||||
upstreamReq.on("error", (error) => {
|
||||
if (!params.res.headersSent) {
|
||||
writeError(params.res, 502, error);
|
||||
return;
|
||||
}
|
||||
params.res.destroy(error);
|
||||
});
|
||||
|
||||
if (params.req.method === "GET" || params.req.method === "HEAD") {
|
||||
upstreamReq.end();
|
||||
return;
|
||||
}
|
||||
params.req.pipe(upstreamReq);
|
||||
}
|
||||
|
||||
export function proxyUpgradeRequest(params: {
|
||||
req: IncomingMessage;
|
||||
socket: Duplex;
|
||||
head: Buffer;
|
||||
target: URL;
|
||||
}) {
|
||||
const requestUrl = new URL(params.req.url ?? "/", "http://127.0.0.1");
|
||||
const port = Number(params.target.port || (params.target.protocol === "https:" ? 443 : 80));
|
||||
const upstream =
|
||||
params.target.protocol === "https:"
|
||||
? tls.connect({
|
||||
host: params.target.hostname,
|
||||
port,
|
||||
servername: params.target.hostname,
|
||||
})
|
||||
: net.connect({
|
||||
host: params.target.hostname,
|
||||
port,
|
||||
});
|
||||
|
||||
const headerLines: string[] = [];
|
||||
for (let index = 0; index < params.req.rawHeaders.length; index += 2) {
|
||||
const name = params.req.rawHeaders[index];
|
||||
const value = params.req.rawHeaders[index + 1] ?? "";
|
||||
if (normalizeLowercaseStringOrEmpty(name) === "host") {
|
||||
continue;
|
||||
}
|
||||
headerLines.push(`${name}: ${value}`);
|
||||
}
|
||||
|
||||
upstream.once("connect", () => {
|
||||
const requestText = [
|
||||
`${params.req.method ?? "GET"} ${rewriteControlUiProxyPath(requestUrl.pathname, requestUrl.search)} HTTP/${params.req.httpVersion}`,
|
||||
`Host: ${params.target.host}`,
|
||||
...headerLines,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
upstream.write(requestText);
|
||||
if (params.head.length > 0) {
|
||||
upstream.write(params.head);
|
||||
}
|
||||
upstream.pipe(params.socket);
|
||||
params.socket.pipe(upstream);
|
||||
});
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!params.socket.destroyed) {
|
||||
params.socket.destroy();
|
||||
}
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
upstream.on("error", () => {
|
||||
if (!params.socket.destroyed) {
|
||||
params.socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||
}
|
||||
closeBoth();
|
||||
});
|
||||
params.socket.on("error", closeBoth);
|
||||
params.socket.on("close", closeBoth);
|
||||
}
|
||||
|
||||
export function tryResolveUiAsset(
|
||||
pathname: string,
|
||||
overrideDir?: string | null,
|
||||
repoRoot = process.cwd(),
|
||||
): string | null {
|
||||
const distDir = resolveUiDistDir(overrideDir, repoRoot);
|
||||
if (!fs.existsSync(distDir)) {
|
||||
return null;
|
||||
}
|
||||
const safePath = pathname === "/" ? "/index.html" : pathname;
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(safePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidate = path.resolve(distDir, `.${decoded.startsWith("/") ? decoded : `/${decoded}`}`);
|
||||
const relative = path.relative(distDir, candidate);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
const fallback = path.join(distDir, "index.html");
|
||||
return fs.existsSync(fallback) ? fallback : null;
|
||||
}
|
||||
@@ -271,6 +271,76 @@ describe("qa-lab server", () => {
|
||||
expect(await rootResponse.text()).toContain("Control UI");
|
||||
});
|
||||
|
||||
it("reports startup reachability for proxy and gateway", async () => {
|
||||
const proxy = createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
||||
res.end("proxy");
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
proxy.once("error", reject);
|
||||
proxy.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
proxy.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
);
|
||||
|
||||
const gateway = createServer((_req, res) => {
|
||||
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
||||
res.end("gateway");
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
gateway.once("error", reject);
|
||||
gateway.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
cleanups.push(
|
||||
async () =>
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
gateway.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
);
|
||||
|
||||
const proxyAddress = proxy.address();
|
||||
const gatewayAddress = gateway.address();
|
||||
if (
|
||||
!proxyAddress ||
|
||||
typeof proxyAddress === "string" ||
|
||||
!gatewayAddress ||
|
||||
typeof gatewayAddress === "string"
|
||||
) {
|
||||
throw new Error("expected startup probe addresses");
|
||||
}
|
||||
|
||||
process.env.OPENCLAW_DEBUG_PROXY_URL = `http://127.0.0.1:${proxyAddress.port}`;
|
||||
const lab = await startQaLabServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
controlUiUrl: `http://127.0.0.1:${gatewayAddress.port}/`,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
delete process.env.OPENCLAW_DEBUG_PROXY_URL;
|
||||
await lab.stop();
|
||||
});
|
||||
|
||||
const response = await fetchWithRetry(`${lab.baseUrl}/api/capture/startup-status`);
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as {
|
||||
status: {
|
||||
proxy: { ok: boolean; url: string };
|
||||
gateway: { ok: boolean; url: string };
|
||||
qaLab: { ok: boolean; url: string };
|
||||
};
|
||||
};
|
||||
expect(payload.status.proxy.ok).toBe(true);
|
||||
expect(payload.status.proxy.url).toBe(`http://127.0.0.1:${proxyAddress.port}/`);
|
||||
expect(payload.status.gateway.ok).toBe(true);
|
||||
expect(payload.status.gateway.url).toBe(`http://127.0.0.1:${gatewayAddress.port}/`);
|
||||
expect(payload.status.qaLab.ok).toBe(true);
|
||||
expect(payload.status.qaLab.url).toBe(lab.baseUrl);
|
||||
});
|
||||
|
||||
it("serves the built QA UI bundle when available", async () => {
|
||||
const uiDistDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-dist-"));
|
||||
cleanups.push(async () => {
|
||||
@@ -296,6 +366,55 @@ describe("qa-lab server", () => {
|
||||
const html = await rootResponse.text();
|
||||
expect(html).not.toContain("QA Lab UI not built");
|
||||
expect(html).toContain("<title>");
|
||||
|
||||
const version1 = (await (await fetch(`${lab.baseUrl}/api/ui-version`)).json()) as {
|
||||
version: string | null;
|
||||
};
|
||||
expect(version1.version).toMatch(/^[0-9a-f]{12}$/);
|
||||
|
||||
await writeFile(
|
||||
path.join(uiDistDir, "index.html"),
|
||||
"<!doctype html><html><head><title>QA Lab Updated</title></head><body><div id='app'></div></body></html>",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const version2 = (await (await fetch(`${lab.baseUrl}/api/ui-version`)).json()) as {
|
||||
version: string | null;
|
||||
};
|
||||
expect(version2.version).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(version2.version).not.toBe(version1.version);
|
||||
});
|
||||
|
||||
it("does not serve sibling files outside the UI dist root", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-ui-boundary-"));
|
||||
cleanups.push(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
const uiDistDir = path.join(rootDir, "dist");
|
||||
const siblingDir = path.join(rootDir, "dist-other");
|
||||
await mkdir(uiDistDir, { recursive: true });
|
||||
await mkdir(siblingDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(uiDistDir, "index.html"),
|
||||
"<!doctype html><html><body>bundle-root</body></html>",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(path.join(siblingDir, "secret.txt"), "sibling-secret", "utf8");
|
||||
|
||||
const lab = await startQaLabServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
uiDistDir,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await lab.stop();
|
||||
});
|
||||
|
||||
const response = await fetchWithRetry(`${lab.baseUrl}/../dist-other/secret.txt`);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.text();
|
||||
expect(body).toContain("bundle-root");
|
||||
expect(body).not.toContain("sibling-secret");
|
||||
});
|
||||
|
||||
it("uses the explicit repo root for ui assets and runner model discovery", async () => {
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import {
|
||||
createServer,
|
||||
request as httpRequest,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from "node:http";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import type { Duplex } from "node:stream";
|
||||
import tls from "node:tls";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
getDebugProxyCaptureStore,
|
||||
resolveDebugProxySettings,
|
||||
} from "openclaw/plugin-sdk/proxy-capture";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { closeQaHttpServer, handleQaBusRequest, writeError, writeJson } from "./bus-server.js";
|
||||
import { createQaBusState, type QaBusState } from "./bus-state.js";
|
||||
import { createQaRunnerRuntime } from "./harness-runtime.js";
|
||||
import {
|
||||
isCaptureQueryPreset,
|
||||
mapCaptureEventForQa,
|
||||
probeTcpReachability,
|
||||
} from "./lab-server-capture.js";
|
||||
import {
|
||||
detectContentType,
|
||||
isControlUiProxyPath,
|
||||
missingUiHtml,
|
||||
proxyHttpRequest,
|
||||
proxyUpgradeRequest,
|
||||
resolveAdvertisedBaseUrl,
|
||||
resolveUiAssetVersion,
|
||||
tryResolveUiAsset,
|
||||
} from "./lab-server-ui.js";
|
||||
import type {
|
||||
QaLabLatestReport,
|
||||
QaLabScenarioOutcome,
|
||||
@@ -42,6 +39,21 @@ import { qaChannelPlugin, setQaChannelRuntime, type OpenClawConfig } from "./run
|
||||
import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
|
||||
import { runQaSelfCheckAgainstState, type QaSelfCheckResult } from "./self-check.js";
|
||||
|
||||
const CAPTURE_QUERY_PRESETS = new Set([
|
||||
"double-sends",
|
||||
"retry-storms",
|
||||
"cache-busting",
|
||||
"ws-duplicate-frames",
|
||||
"missing-ack",
|
||||
"error-bursts",
|
||||
]);
|
||||
|
||||
function isCaptureQueryPreset(
|
||||
value: string,
|
||||
): value is Parameters<ReturnType<typeof getDebugProxyCaptureStore>["queryPreset"]>[0] {
|
||||
return CAPTURE_QUERY_PRESETS.has(value);
|
||||
}
|
||||
|
||||
type QaLabBootstrapDefaults = {
|
||||
conversationKind: "direct" | "channel";
|
||||
conversationId: string;
|
||||
@@ -57,6 +69,112 @@ export type {
|
||||
QaLabServerStartParams,
|
||||
} from "./lab-server.types.js";
|
||||
|
||||
function parseCaptureMeta(metaJson: unknown): Record<string, unknown> | null {
|
||||
if (typeof metaJson !== "string" || metaJson.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(metaJson) as unknown;
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCaptureMetaString(
|
||||
meta: Record<string, unknown> | null,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const value = meta?.[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function mapCaptureEventForQa(row: Record<string, unknown>) {
|
||||
const meta = parseCaptureMeta(row.metaJson);
|
||||
return {
|
||||
...row,
|
||||
payloadPreview: typeof row.dataText === "string" ? row.dataText : undefined,
|
||||
provider: readCaptureMetaString(meta, "provider"),
|
||||
api: readCaptureMetaString(meta, "api"),
|
||||
model: readCaptureMetaString(meta, "model"),
|
||||
captureOrigin: readCaptureMetaString(meta, "captureOrigin"),
|
||||
};
|
||||
}
|
||||
|
||||
type QaStartupProbeStatus = {
|
||||
label: string;
|
||||
url: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function defaultPortForProtocol(protocol: string): number {
|
||||
if (protocol === "https:") {
|
||||
return 443;
|
||||
}
|
||||
if (protocol === "http:") {
|
||||
return 80;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function probeTcpReachability(
|
||||
rawUrl: string,
|
||||
timeoutMs = 700,
|
||||
): Promise<QaStartupProbeStatus> {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return {
|
||||
label: rawUrl,
|
||||
url: rawUrl,
|
||||
ok: false,
|
||||
error: "invalid url",
|
||||
};
|
||||
}
|
||||
const host = parsed.hostname;
|
||||
const port = parsed.port ? Number(parsed.port) : defaultPortForProtocol(parsed.protocol);
|
||||
if (!host || !Number.isFinite(port) || port <= 0) {
|
||||
return {
|
||||
label: parsed.origin,
|
||||
url: parsed.toString(),
|
||||
ok: false,
|
||||
error: "missing host or port",
|
||||
};
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ host, port });
|
||||
const onError = (error: Error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
socket.setTimeout(timeoutMs, () => {
|
||||
socket.destroy(new Error("timeout"));
|
||||
});
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", onError);
|
||||
socket.once("timeout", () => onError(new Error("timeout")));
|
||||
});
|
||||
return {
|
||||
label: parsed.host,
|
||||
url: parsed.toString(),
|
||||
ok: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
label: parsed.host,
|
||||
url: parsed.toString(),
|
||||
ok: false,
|
||||
error: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function countQaLabScenarioRun(scenarios: QaLabScenarioOutcome[]) {
|
||||
return {
|
||||
total: scenarios.length,
|
||||
@@ -103,6 +221,121 @@ async function readJson(req: IncomingMessage): Promise<unknown> {
|
||||
return text ? (JSON.parse(text) as unknown) : {};
|
||||
}
|
||||
|
||||
function detectContentType(filePath: string): string {
|
||||
if (filePath.endsWith(".css")) {
|
||||
return "text/css; charset=utf-8";
|
||||
}
|
||||
if (filePath.endsWith(".js")) {
|
||||
return "text/javascript; charset=utf-8";
|
||||
}
|
||||
if (filePath.endsWith(".json")) {
|
||||
return "application/json; charset=utf-8";
|
||||
}
|
||||
if (filePath.endsWith(".svg")) {
|
||||
return "image/svg+xml";
|
||||
}
|
||||
return "text/html; charset=utf-8";
|
||||
}
|
||||
|
||||
function missingUiHtml() {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>QA Lab UI Missing</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; background: #0f1115; color: #f5f7fb; margin: 0; display: grid; place-items: center; min-height: 100vh; }
|
||||
main { max-width: 42rem; padding: 2rem; background: #171b22; border: 1px solid #283140; border-radius: 18px; box-shadow: 0 30px 80px rgba(0,0,0,.35); }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #9ee8d8; }
|
||||
h1 { margin-top: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>QA Lab UI not built</h1>
|
||||
<p>Build the private debugger bundle, then reload this page.</p>
|
||||
<p><code>pnpm qa:lab:build</code></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function resolveUiDistDir(overrideDir?: string | null, repoRoot = process.cwd()) {
|
||||
if (overrideDir?.trim()) {
|
||||
return overrideDir;
|
||||
}
|
||||
const candidates = [
|
||||
path.resolve(repoRoot, "extensions/qa-lab/web/dist"),
|
||||
path.resolve(repoRoot, "dist/extensions/qa-lab/web/dist"),
|
||||
fileURLToPath(new URL("../web/dist", import.meta.url)),
|
||||
];
|
||||
return (
|
||||
candidates.find((candidate) => {
|
||||
if (!fs.existsSync(candidate)) {
|
||||
return false;
|
||||
}
|
||||
const indexPath = path.join(candidate, "index.html");
|
||||
return fs.existsSync(indexPath) && fs.statSync(indexPath).isFile();
|
||||
}) ?? candidates[0]
|
||||
);
|
||||
}
|
||||
|
||||
function listUiAssetFiles(rootDir: string, currentDir = rootDir): string[] {
|
||||
const entries = fs
|
||||
.readdirSync(currentDir, { withFileTypes: true })
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const resolved = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...listUiAssetFiles(rootDir, resolved));
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
files.push(path.relative(rootDir, resolved));
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function resolveUiAssetVersion(overrideDir?: string | null): string | null {
|
||||
try {
|
||||
const distDir = resolveUiDistDir(overrideDir);
|
||||
const indexPath = path.join(distDir, "index.html");
|
||||
if (!fs.existsSync(indexPath) || !fs.statSync(indexPath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
const hash = createHash("sha1");
|
||||
for (const relativeFile of listUiAssetFiles(distDir)) {
|
||||
hash.update(relativeFile);
|
||||
hash.update("\0");
|
||||
hash.update(fs.readFileSync(path.join(distDir, relativeFile)));
|
||||
hash.update("\0");
|
||||
}
|
||||
return hash.digest("hex").slice(0, 12);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAdvertisedBaseUrl(params: {
|
||||
bindHost?: string;
|
||||
bindPort: number;
|
||||
advertiseHost?: string;
|
||||
advertisePort?: number;
|
||||
}) {
|
||||
const advertisedHost =
|
||||
params.advertiseHost?.trim() ||
|
||||
(params.bindHost && params.bindHost !== "0.0.0.0" ? params.bindHost : "127.0.0.1");
|
||||
const advertisedPort =
|
||||
typeof params.advertisePort === "number" && Number.isFinite(params.advertisePort)
|
||||
? params.advertisePort
|
||||
: params.bindPort;
|
||||
return `http://${advertisedHost}:${advertisedPort}`;
|
||||
}
|
||||
|
||||
function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefaults {
|
||||
if (autoKickoffTarget === "channel") {
|
||||
return {
|
||||
@@ -120,6 +353,163 @@ function createBootstrapDefaults(autoKickoffTarget?: string): QaLabBootstrapDefa
|
||||
};
|
||||
}
|
||||
|
||||
function isControlUiProxyPath(pathname: string) {
|
||||
return pathname === "/control-ui" || pathname.startsWith("/control-ui/");
|
||||
}
|
||||
|
||||
function rewriteControlUiProxyPath(pathname: string, search: string) {
|
||||
const stripped = pathname === "/control-ui" ? "/" : pathname.slice("/control-ui".length) || "/";
|
||||
return `${stripped}${search}`;
|
||||
}
|
||||
|
||||
function rewriteEmbeddedControlUiHeaders(
|
||||
headers: IncomingMessage["headers"],
|
||||
): Record<string, string | string[] | number | undefined> {
|
||||
const rewritten: Record<string, string | string[] | number | undefined> = { ...headers };
|
||||
delete rewritten["x-frame-options"];
|
||||
|
||||
const csp = headers["content-security-policy"];
|
||||
if (typeof csp === "string") {
|
||||
rewritten["content-security-policy"] = csp.includes("frame-ancestors")
|
||||
? csp.replace(/frame-ancestors\s+[^;]+/i, "frame-ancestors 'self'")
|
||||
: `${csp}; frame-ancestors 'self'`;
|
||||
}
|
||||
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
async function proxyHttpRequest(params: {
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
target: URL;
|
||||
pathname: string;
|
||||
search: string;
|
||||
}) {
|
||||
const client = params.target.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const upstreamReq = client(
|
||||
{
|
||||
protocol: params.target.protocol,
|
||||
hostname: params.target.hostname,
|
||||
port: params.target.port || (params.target.protocol === "https:" ? 443 : 80),
|
||||
method: params.req.method,
|
||||
path: rewriteControlUiProxyPath(params.pathname, params.search),
|
||||
headers: {
|
||||
...params.req.headers,
|
||||
host: params.target.host,
|
||||
},
|
||||
},
|
||||
(upstreamRes) => {
|
||||
params.res.writeHead(
|
||||
upstreamRes.statusCode ?? 502,
|
||||
rewriteEmbeddedControlUiHeaders(upstreamRes.headers),
|
||||
);
|
||||
upstreamRes.pipe(params.res);
|
||||
},
|
||||
);
|
||||
|
||||
upstreamReq.on("error", (error) => {
|
||||
if (!params.res.headersSent) {
|
||||
writeError(params.res, 502, error);
|
||||
return;
|
||||
}
|
||||
params.res.destroy(error);
|
||||
});
|
||||
|
||||
if (params.req.method === "GET" || params.req.method === "HEAD") {
|
||||
upstreamReq.end();
|
||||
return;
|
||||
}
|
||||
params.req.pipe(upstreamReq);
|
||||
}
|
||||
|
||||
function proxyUpgradeRequest(params: {
|
||||
req: IncomingMessage;
|
||||
socket: Duplex;
|
||||
head: Buffer;
|
||||
target: URL;
|
||||
}) {
|
||||
const requestUrl = new URL(params.req.url ?? "/", "http://127.0.0.1");
|
||||
const port = Number(params.target.port || (params.target.protocol === "https:" ? 443 : 80));
|
||||
const upstream =
|
||||
params.target.protocol === "https:"
|
||||
? tls.connect({
|
||||
host: params.target.hostname,
|
||||
port,
|
||||
servername: params.target.hostname,
|
||||
})
|
||||
: net.connect({
|
||||
host: params.target.hostname,
|
||||
port,
|
||||
});
|
||||
|
||||
const headerLines: string[] = [];
|
||||
for (let index = 0; index < params.req.rawHeaders.length; index += 2) {
|
||||
const name = params.req.rawHeaders[index];
|
||||
const value = params.req.rawHeaders[index + 1] ?? "";
|
||||
if (normalizeLowercaseStringOrEmpty(name) === "host") {
|
||||
continue;
|
||||
}
|
||||
headerLines.push(`${name}: ${value}`);
|
||||
}
|
||||
|
||||
upstream.once("connect", () => {
|
||||
const requestText = [
|
||||
`${params.req.method ?? "GET"} ${rewriteControlUiProxyPath(requestUrl.pathname, requestUrl.search)} HTTP/${params.req.httpVersion}`,
|
||||
`Host: ${params.target.host}`,
|
||||
...headerLines,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
upstream.write(requestText);
|
||||
if (params.head.length > 0) {
|
||||
upstream.write(params.head);
|
||||
}
|
||||
upstream.pipe(params.socket);
|
||||
params.socket.pipe(upstream);
|
||||
});
|
||||
|
||||
const closeBoth = () => {
|
||||
if (!params.socket.destroyed) {
|
||||
params.socket.destroy();
|
||||
}
|
||||
if (!upstream.destroyed) {
|
||||
upstream.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
upstream.on("error", () => {
|
||||
if (!params.socket.destroyed) {
|
||||
params.socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
|
||||
}
|
||||
closeBoth();
|
||||
});
|
||||
params.socket.on("error", closeBoth);
|
||||
params.socket.on("close", closeBoth);
|
||||
}
|
||||
|
||||
function tryResolveUiAsset(
|
||||
pathname: string,
|
||||
overrideDir?: string | null,
|
||||
repoRoot = process.cwd(),
|
||||
): string | null {
|
||||
const distDir = resolveUiDistDir(overrideDir, repoRoot);
|
||||
if (!fs.existsSync(distDir)) {
|
||||
return null;
|
||||
}
|
||||
const safePath = pathname === "/" ? "/index.html" : pathname;
|
||||
const decoded = decodeURIComponent(safePath);
|
||||
const candidate = path.resolve(distDir, `.${decoded.startsWith("/") ? decoded : `/${decoded}`}`);
|
||||
const relative = path.relative(distDir, candidate);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
const fallback = path.join(distDir, "index.html");
|
||||
return fs.existsSync(fallback) ? fallback : null;
|
||||
}
|
||||
|
||||
function createQaLabConfig(baseUrl: string): OpenClawConfig {
|
||||
return createQaChannelGatewayConfig({ baseUrl });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
QA_CHANNEL_REQUIRED_PLUGIN_IDS,
|
||||
} from "./qa-channel-transport.js";
|
||||
import { buildQaGatewayConfig } from "./qa-gateway-config.js";
|
||||
import { resolveQaNodeExecPath } from "./node-exec.js";
|
||||
|
||||
const QA_FRONTIER_PROVIDER_IDS = ["anthropic", "google", "openai"] as const;
|
||||
|
||||
@@ -124,12 +123,11 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa
|
||||
|
||||
const stdout: Buffer[] = [];
|
||||
const stderr: Buffer[] = [];
|
||||
const nodeExecPath = await resolveQaNodeExecPath();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let aborted = params.signal?.aborted === true;
|
||||
let forceKillTimer: NodeJS.Timeout | undefined;
|
||||
const child = spawn(
|
||||
nodeExecPath,
|
||||
process.execPath,
|
||||
["dist/index.js", "models", "list", "--all", "--json"],
|
||||
{
|
||||
cwd: params.repoRoot,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveQaNodeExecPath } from "./node-exec.js";
|
||||
|
||||
describe("resolveQaNodeExecPath", () => {
|
||||
it("reuses the current exec path when already running under Node", async () => {
|
||||
await expect(
|
||||
resolveQaNodeExecPath({
|
||||
execPath: "/opt/homebrew/bin/node",
|
||||
platform: "darwin",
|
||||
versions: { ...process.versions, bun: undefined },
|
||||
}),
|
||||
).resolves.toBe("/opt/homebrew/bin/node");
|
||||
});
|
||||
|
||||
it("reuses nodejs as a valid current Node executable", async () => {
|
||||
await expect(
|
||||
resolveQaNodeExecPath({
|
||||
execPath: "/usr/bin/nodejs",
|
||||
platform: "linux",
|
||||
versions: { ...process.versions, bun: undefined },
|
||||
execFileImpl: async () => {
|
||||
throw new Error("should not search PATH");
|
||||
},
|
||||
}),
|
||||
).resolves.toBe("/usr/bin/nodejs");
|
||||
});
|
||||
|
||||
it("resolves node from PATH when the parent runtime is bun", async () => {
|
||||
await expect(
|
||||
resolveQaNodeExecPath({
|
||||
execPath: "/opt/homebrew/bin/bun",
|
||||
platform: "darwin",
|
||||
versions: { ...process.versions, bun: "1.2.3" },
|
||||
execFileImpl: async () => ({
|
||||
stdout: "/usr/local/bin/node\n",
|
||||
stderr: "",
|
||||
}),
|
||||
}),
|
||||
).resolves.toBe("/usr/local/bin/node");
|
||||
});
|
||||
|
||||
it("throws a clear error when node is unavailable", async () => {
|
||||
await expect(
|
||||
resolveQaNodeExecPath({
|
||||
execPath: "/opt/homebrew/bin/bun",
|
||||
platform: "darwin",
|
||||
versions: { ...process.versions, bun: "1.2.3" },
|
||||
execFileImpl: async () => {
|
||||
throw new Error("missing");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Node not found in PATH");
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
type ExecFileAsync = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: {
|
||||
encoding: "utf8";
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
const execFileAsync = promisify(execFile) as unknown as ExecFileAsync;
|
||||
|
||||
function isNodeExecPath(execPath: string, platform: NodeJS.Platform): boolean {
|
||||
const pathModule = platform === "win32" ? path.win32 : path.posix;
|
||||
const basename = pathModule.basename(execPath).toLowerCase();
|
||||
return (
|
||||
basename === "node" ||
|
||||
basename === "node.exe" ||
|
||||
basename === "nodejs" ||
|
||||
basename === "nodejs.exe"
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveQaNodeExecPath(params?: {
|
||||
execPath?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
versions?: NodeJS.ProcessVersions;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
execFileImpl?: ExecFileAsync;
|
||||
}): Promise<string> {
|
||||
const execPath = params?.execPath ?? process.execPath;
|
||||
const platform = params?.platform ?? process.platform;
|
||||
const versions = params?.versions ?? process.versions;
|
||||
if (typeof versions.bun !== "string" && isNodeExecPath(execPath, platform)) {
|
||||
return execPath;
|
||||
}
|
||||
|
||||
const locator = platform === "win32" ? "where" : "which";
|
||||
const execFileImpl = params?.execFileImpl ?? execFileAsync;
|
||||
let stdout = "";
|
||||
try {
|
||||
({ stdout } = await execFileImpl(locator, ["node"], {
|
||||
encoding: "utf8",
|
||||
env: params?.env,
|
||||
}));
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Node not found in PATH. QA live lanes require Node for child gateway and CLI processes.",
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
"Node not found in PATH. QA live lanes require Node for child gateway and CLI processes.",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
isNodeExecPath,
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import { qaChannelPlugin } from "./runtime-api.js";
|
||||
export const QA_CHANNEL_ID = "qa-channel";
|
||||
export const QA_CHANNEL_ACCOUNT_ID = "default";
|
||||
export const QA_CHANNEL_REQUIRED_PLUGIN_IDS = Object.freeze([QA_CHANNEL_ID]);
|
||||
export const QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY = 4;
|
||||
|
||||
async function waitForQaChannelReady(params: {
|
||||
gateway: QaTransportGatewayClient;
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeQaTransportId } from "./qa-transport-registry.js";
|
||||
|
||||
describe("qa transport registry", () => {
|
||||
it("rejects inherited prototype keys as unsupported transport ids", () => {
|
||||
expect(() => normalizeQaTransportId("toString")).toThrow("unsupported QA transport: toString");
|
||||
expect(() => normalizeQaTransportId("__proto__")).toThrow(
|
||||
"unsupported QA transport: __proto__",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,29 @@
|
||||
import type { QaBusState } from "./bus-state.js";
|
||||
import {
|
||||
createQaChannelTransport,
|
||||
QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY,
|
||||
} from "./qa-channel-transport.js";
|
||||
import { createQaChannelTransport } from "./qa-channel-transport.js";
|
||||
import type { QaTransportAdapter } from "./qa-transport.js";
|
||||
|
||||
export type QaTransportId = "qa-channel";
|
||||
|
||||
const DEFAULT_QA_TRANSPORT_ID: QaTransportId = "qa-channel";
|
||||
|
||||
const QA_TRANSPORT_REGISTRY = {
|
||||
"qa-channel": {
|
||||
create: createQaChannelTransport,
|
||||
defaultSuiteConcurrency: QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY,
|
||||
},
|
||||
} as const satisfies Record<
|
||||
QaTransportId,
|
||||
{
|
||||
create: (state: QaBusState) => QaTransportAdapter;
|
||||
defaultSuiteConcurrency: number;
|
||||
}
|
||||
>;
|
||||
|
||||
export function normalizeQaTransportId(input?: string | null): QaTransportId {
|
||||
const transportId = input?.trim() || DEFAULT_QA_TRANSPORT_ID;
|
||||
if (Object.hasOwn(QA_TRANSPORT_REGISTRY, transportId)) {
|
||||
return transportId as QaTransportId;
|
||||
const transportId = input?.trim() || "qa-channel";
|
||||
switch (transportId) {
|
||||
case "qa-channel":
|
||||
return transportId;
|
||||
default:
|
||||
throw new Error(`unsupported QA transport: ${transportId}`);
|
||||
}
|
||||
throw new Error(`unsupported QA transport: ${transportId}`);
|
||||
}
|
||||
|
||||
export function createQaTransportAdapter(params: {
|
||||
id: QaTransportId;
|
||||
state: QaBusState;
|
||||
}): QaTransportAdapter {
|
||||
return QA_TRANSPORT_REGISTRY[params.id].create(params.state);
|
||||
}
|
||||
|
||||
export function defaultQaSuiteConcurrencyForTransport(id: QaTransportId): number {
|
||||
return QA_TRANSPORT_REGISTRY[id].defaultSuiteConcurrency;
|
||||
switch (params.id) {
|
||||
case "qa-channel":
|
||||
return createQaChannelTransport(params.state);
|
||||
default: {
|
||||
const unsupported: never = params.id;
|
||||
throw new Error(`unsupported QA transport: ${String(unsupported)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user