mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 16:03:47 +08:00
Compare commits
146 Commits
fix/exec-a
...
fix/codeql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f65fce4a | ||
|
|
9fc5f061e2 | ||
|
|
9b055ee2a3 | ||
|
|
457b2ee175 | ||
|
|
7e9ff0f86e | ||
|
|
12a59b0a18 | ||
|
|
baf11b83d7 | ||
|
|
3a59eddd07 | ||
|
|
2cfb660a9b | ||
|
|
42805d26cf | ||
|
|
7e659e168b | ||
|
|
94081d8863 | ||
|
|
bb7e9823a8 | ||
|
|
4acab55db8 | ||
|
|
f4853115a9 | ||
|
|
6ba8626c25 | ||
|
|
dbc8179f31 | ||
|
|
cd330f5f98 | ||
|
|
fd48dfa68f | ||
|
|
2e08c77582 | ||
|
|
a2753e2d9f | ||
|
|
c73a6d2f68 | ||
|
|
272536015f | ||
|
|
59b98334f6 | ||
|
|
0dc4c4076c | ||
|
|
26db52ed69 | ||
|
|
0c5bdbde89 | ||
|
|
5c1c52f870 | ||
|
|
8507935d3a | ||
|
|
992ff81ae1 | ||
|
|
6878c19449 | ||
|
|
f8bac822b6 | ||
|
|
ed04d38bec | ||
|
|
ce1be0f43d | ||
|
|
81818df1b4 | ||
|
|
b21540fabc | ||
|
|
350aa6343a | ||
|
|
b2cae7f12a | ||
|
|
a98754d504 | ||
|
|
d59604b15e | ||
|
|
041266a669 | ||
|
|
4d2854a2b0 | ||
|
|
80e78f7b90 | ||
|
|
fc137ec5e3 | ||
|
|
63e53fbf2e | ||
|
|
98c681e033 | ||
|
|
678b019467 | ||
|
|
dafc71c913 | ||
|
|
3ae5d95bfd | ||
|
|
012b577e84 | ||
|
|
8a37bb4ed6 | ||
|
|
cd45f53b4e | ||
|
|
c9103c2e47 | ||
|
|
f835da1667 | ||
|
|
56a9fd4b34 | ||
|
|
988447ca24 | ||
|
|
0f7c40e508 | ||
|
|
21d500a65f | ||
|
|
5bb180061a | ||
|
|
372c0051ba | ||
|
|
8b7d76bfbb | ||
|
|
894e728fd0 | ||
|
|
5262757f9a | ||
|
|
59caf03d67 | ||
|
|
36dd58ac2a | ||
|
|
51606e9889 | ||
|
|
781b1de921 | ||
|
|
2285429aa2 | ||
|
|
29427fefc7 | ||
|
|
15c7f478da | ||
|
|
006a8aeb8c | ||
|
|
ad9da24317 | ||
|
|
c635efd233 | ||
|
|
a327b6750d | ||
|
|
ac717a92e8 | ||
|
|
c2db918c60 | ||
|
|
42d100c390 | ||
|
|
82e349a48a | ||
|
|
00d21d1b23 | ||
|
|
900e291f31 | ||
|
|
687ede50a5 | ||
|
|
f624b1d246 | ||
|
|
1d27e0ef08 | ||
|
|
b31d243c57 | ||
|
|
c4488d5ef5 | ||
|
|
a4b94f77b9 | ||
|
|
77d9fd693f | ||
|
|
6429fa0a7f | ||
|
|
eb10803691 | ||
|
|
1183832d4f | ||
|
|
d842ec4179 | ||
|
|
e95efa4373 | ||
|
|
86f108401b | ||
|
|
f4bbd0122a | ||
|
|
69ba924b53 | ||
|
|
16c608e393 | ||
|
|
1d41ef724a | ||
|
|
892baf2e81 | ||
|
|
99dfc1b616 | ||
|
|
728295c046 | ||
|
|
d74533c718 | ||
|
|
461d0050d9 | ||
|
|
4c66978591 | ||
|
|
1a98090bf3 | ||
|
|
628b454eff | ||
|
|
75c551e89e | ||
|
|
29919bb6e4 | ||
|
|
69d25f5f16 | ||
|
|
8c11210fe5 | ||
|
|
c3c7a9953f | ||
|
|
de129a6530 | ||
|
|
3525273930 | ||
|
|
d7f489f85e | ||
|
|
b555214c96 | ||
|
|
f44ab20d4d | ||
|
|
36ed36768c | ||
|
|
b23d59a522 | ||
|
|
e588e904a7 | ||
|
|
55f05df77e | ||
|
|
e485f24301 | ||
|
|
90801ba400 | ||
|
|
f697b01747 | ||
|
|
44a6e50fcc | ||
|
|
fbccc18e74 | ||
|
|
2c2dc00fb4 | ||
|
|
01b7516a95 | ||
|
|
6ea3cddf0d | ||
|
|
ecfaf64526 | ||
|
|
05cac5b980 | ||
|
|
4db162db7f | ||
|
|
8ecb6bbb12 | ||
|
|
51b5d16faf | ||
|
|
bf59917cd1 | ||
|
|
b10ae0bf13 | ||
|
|
6f5459364a | ||
|
|
b878d50e0e | ||
|
|
4af7641350 | ||
|
|
405c63fb32 | ||
|
|
78df859e15 | ||
|
|
898fd0482a | ||
|
|
c1817c62e3 | ||
|
|
3a3fae0eac | ||
|
|
6c343f1f58 | ||
|
|
b8ef507cc0 | ||
|
|
c56b56e514 | ||
|
|
053c5b05c1 |
@@ -22,7 +22,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Windows: `90m`
|
||||
- aggregate npm-update wrapper: `150m`
|
||||
If a lane hits the cap, stop there, inspect the newest `/tmp/openclaw-parallels-*` run directory and phase log, then fix or rerun the smallest affected lane. Do not keep waiting on a capped lane.
|
||||
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: they should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, `install-baseline-package`, `update-dev`, or same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
|
||||
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: install phases should finish within 7 minutes, and update phases should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
|
||||
- For a full OS matrix, prefer running independent guest-family lanes in parallel when host capacity allows:
|
||||
- `timeout --foreground 75m pnpm test:parallels:macos -- --json`
|
||||
- `timeout --foreground 90m pnpm test:parallels:windows -- --json`
|
||||
|
||||
2
.github/actionlint.yaml
vendored
2
.github/actionlint.yaml
vendored
@@ -11,6 +11,8 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-windows-2025
|
||||
- blacksmith-32vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-latest
|
||||
- blacksmith-12vcpu-macos-latest
|
||||
|
||||
# Ignore patterns for known issues
|
||||
paths:
|
||||
|
||||
144
.github/workflows/ci.yml
vendored
144
.github/workflows/ci.yml
vendored
@@ -20,6 +20,8 @@ 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
|
||||
@@ -298,6 +300,8 @@ 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
|
||||
@@ -396,6 +400,8 @@ 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
|
||||
@@ -449,6 +455,8 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
checks-fast-core:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
@@ -493,6 +501,8 @@ jobs:
|
||||
esac
|
||||
|
||||
checks-node-extensions-shard:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
@@ -520,6 +530,8 @@ 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'
|
||||
@@ -536,6 +548,8 @@ 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'
|
||||
@@ -622,6 +636,8 @@ 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'
|
||||
@@ -632,10 +648,51 @@ jobs:
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_test_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target"
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA"
|
||||
echo "checkout attempt ${attempt}/2 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/2 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 2 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -711,6 +768,8 @@ 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'
|
||||
@@ -727,6 +786,8 @@ jobs:
|
||||
fi
|
||||
|
||||
extension-fast:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "extension-fast"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_extension_fast == 'true'
|
||||
@@ -755,6 +816,8 @@ jobs:
|
||||
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check"
|
||||
needs: [preflight]
|
||||
if: always() && needs.preflight.outputs.run_check == 'true'
|
||||
@@ -782,6 +845,8 @@ 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'
|
||||
@@ -989,6 +1054,8 @@ 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')
|
||||
@@ -1043,6 +1110,8 @@ 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
|
||||
@@ -1064,6 +1133,8 @@ 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
|
||||
@@ -1092,6 +1163,8 @@ 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'
|
||||
@@ -1207,10 +1280,12 @@ 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'
|
||||
runs-on: macos-latest
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1239,6 +1314,30 @@ jobs:
|
||||
name: canvas-a2ui-bundle
|
||||
path: src/canvas-host/a2ui/
|
||||
|
||||
- name: Patch mlx-audio-swift manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
swift package resolve --package-path apps/macos >/dev/null
|
||||
chmod u+w apps/macos/.build/checkouts/mlx-audio-swift/Package.swift
|
||||
python <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("apps/macos/.build/checkouts/mlx-audio-swift/Package.swift")
|
||||
text = path.read_text()
|
||||
if "Models/Qwen3/README.md" in text:
|
||||
print("mlx-audio-swift README excludes already present")
|
||||
raise SystemExit(0)
|
||||
|
||||
needle = ' path: "Sources/MLXAudioTTS"\n'
|
||||
replacement = """ path: \"Sources/MLXAudioTTS\",\n exclude: [\n \"Models/Llama/README.md\",\n \"Models/Marvis/README.md\",\n \"Models/PocketTTS/README.md\",\n \"Models/Qwen3/README.md\",\n \"Models/Soprano/README.md\",\n ]\n"""
|
||||
|
||||
if needle not in text:
|
||||
raise SystemExit("Could not find MLXAudioTTS target path in mlx-audio-swift Package.swift")
|
||||
|
||||
path.write_text(text.replace(needle, replacement, 1))
|
||||
print(f"Patched {path}")
|
||||
PY
|
||||
|
||||
- name: TS tests (macOS)
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
@@ -1260,10 +1359,12 @@ jobs:
|
||||
esac
|
||||
|
||||
macos-swift:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "macos-swift"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||||
runs-on: macos-latest
|
||||
runs-on: blacksmith-12vcpu-macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1272,11 +1373,6 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
@@ -1288,6 +1384,30 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-swiftpm-
|
||||
|
||||
- name: Patch mlx-audio-swift manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
swift package resolve --package-path apps/macos >/dev/null
|
||||
chmod u+w apps/macos/.build/checkouts/mlx-audio-swift/Package.swift
|
||||
python <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("apps/macos/.build/checkouts/mlx-audio-swift/Package.swift")
|
||||
text = path.read_text()
|
||||
if "Models/Qwen3/README.md" in text:
|
||||
print("mlx-audio-swift README excludes already present")
|
||||
raise SystemExit(0)
|
||||
|
||||
needle = ' path: "Sources/MLXAudioTTS"\n'
|
||||
replacement = """ path: \"Sources/MLXAudioTTS\",\n exclude: [\n \"Models/Llama/README.md\",\n \"Models/Marvis/README.md\",\n \"Models/PocketTTS/README.md\",\n \"Models/Qwen3/README.md\",\n \"Models/Soprano/README.md\",\n ]\n"""
|
||||
|
||||
if needle not in text:
|
||||
raise SystemExit("Could not find MLXAudioTTS target path in mlx-audio-swift Package.swift")
|
||||
|
||||
path.write_text(text.replace(needle, replacement, 1))
|
||||
print(f"Patched {path}")
|
||||
PY
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
sw_vers
|
||||
@@ -1324,6 +1444,8 @@ jobs:
|
||||
exit 1
|
||||
|
||||
android:
|
||||
permissions:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_android_job == 'true'
|
||||
|
||||
20
.github/workflows/codeql.yml
vendored
20
.github/workflows/codeql.yml
vendored
@@ -1,7 +1,15 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
- "LICENSE"
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
@@ -70,7 +78,7 @@ jobs:
|
||||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -83,13 +91,13 @@ jobs:
|
||||
|
||||
- name: Setup Python
|
||||
if: matrix.needs_python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Java
|
||||
if: matrix.needs_java
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
@@ -103,7 +111,7 @@ jobs:
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: security-and-quality
|
||||
@@ -111,7 +119,7 @@ jobs:
|
||||
|
||||
- name: Autobuild
|
||||
if: matrix.needs_autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # v4
|
||||
|
||||
- name: Build Android for CodeQL
|
||||
if: matrix.language == 'java-kotlin'
|
||||
@@ -132,6 +140,6 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@b25d0ebf40e5b63ee81e1bd6e5d2a12b7c2aeb61 # 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@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # 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@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -200,10 +200,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # 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@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # 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@v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
13
.github/workflows/install-smoke.yml
vendored
13
.github/workflows/install-smoke.yml
vendored
@@ -7,6 +7,9 @@ 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' }}
|
||||
@@ -92,12 +95,12 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # 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@v2
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -116,7 +119,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@v2
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -174,7 +177,7 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Build installer smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
file: ./scripts/docker/install-sh-smoke/Dockerfile
|
||||
@@ -185,7 +188,7 @@ jobs:
|
||||
|
||||
- name: Build installer non-root image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
file: ./scripts/docker/install-sh-nonroot/Dockerfile
|
||||
|
||||
@@ -1,12 +1,67 @@
|
||||
name: OpenClaw Cross-OS Release Checks (Reusable)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
workflow_ref:
|
||||
description: Optional openclaw/openclaw ref that provides the reusable workflow harness
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
provider:
|
||||
description: Provider lane to use for onboarding and the end-to-end turn
|
||||
required: true
|
||||
default: openai
|
||||
type: choice
|
||||
options:
|
||||
- openai
|
||||
- anthropic
|
||||
- minimax
|
||||
mode:
|
||||
description: Which release-check lanes to run
|
||||
required: true
|
||||
default: both
|
||||
type: choice
|
||||
options:
|
||||
- fresh
|
||||
- upgrade
|
||||
- both
|
||||
previous_version:
|
||||
description: Optional baseline version for installer/dev-update and packaged upgrade
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ubuntu_runner:
|
||||
description: Optional Linux runner label override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
windows_runner:
|
||||
description: Optional Windows runner label override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
macos_runner:
|
||||
description: Optional macOS runner label override
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
|
||||
required: true
|
||||
type: string
|
||||
workflow_ref:
|
||||
description: Optional openclaw/openclaw ref that provides the reusable workflow harness
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
provider:
|
||||
description: Provider lane to use for onboarding and the end-to-end turn
|
||||
required: true
|
||||
@@ -42,6 +97,14 @@ on:
|
||||
required: false
|
||||
MINIMAX_API_KEY:
|
||||
required: false
|
||||
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN:
|
||||
required: false
|
||||
OPENCLAW_DISCORD_SMOKE_GUILD_ID:
|
||||
required: false
|
||||
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID:
|
||||
required: false
|
||||
|
||||
permissions: read-all
|
||||
|
||||
concurrency:
|
||||
group: openclaw-cross-os-release-checks-${{ inputs.ref }}-${{ inputs.provider }}-${{ inputs.mode }}
|
||||
@@ -52,12 +115,11 @@ env:
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
OPENCLAW_REPOSITORY: openclaw/openclaw
|
||||
TSX_VERSION: "4.21.0"
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
|
||||
baseline_spec: ${{ steps.baseline.outputs.value }}
|
||||
@@ -65,6 +127,7 @@ jobs:
|
||||
candidate_version: ${{ steps.candidate_metadata.outputs.version }}
|
||||
matrix: ${{ steps.matrix.outputs.value }}
|
||||
source_sha: ${{ steps.candidate_metadata.outputs.source_sha }}
|
||||
workflow_ref: ${{ steps.workflow_ref.outputs.value }}
|
||||
steps:
|
||||
- name: Validate provider secret availability
|
||||
env:
|
||||
@@ -90,9 +153,109 @@ jobs:
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout caller release workflow repo
|
||||
- name: Resolve workflow ref
|
||||
id: workflow_ref
|
||||
env:
|
||||
INPUT_WORKFLOW_REF: ${{ inputs.workflow_ref }}
|
||||
CALLER_REPOSITORY: ${{ github.repository }}
|
||||
CURRENT_SHA: ${{ github.sha }}
|
||||
WORKFLOW_CONTEXT_REF: ${{ github.workflow_ref }}
|
||||
WORKFLOW_REPOSITORY: ${{ env.OPENCLAW_REPOSITORY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
resolve_unique_remote_ref() {
|
||||
local remote_url="$1"
|
||||
shift
|
||||
local -a refs=("$@")
|
||||
local -a matches=()
|
||||
local ref=""
|
||||
|
||||
for ref in "${refs[@]}"; do
|
||||
[[ -n "${ref}" ]] || continue
|
||||
mapfile -t matches < <(
|
||||
git ls-remote "${remote_url}" "${ref}" | awk '{print $1}' | awk '!seen[$0]++'
|
||||
)
|
||||
if [[ "${#matches[@]}" -eq 0 ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "${#matches[@]}" -ne 1 ]]; then
|
||||
return 2
|
||||
fi
|
||||
|
||||
printf '%s\n' "${matches[0]}"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -n "${INPUT_WORKFLOW_REF}" ]]; then
|
||||
TARGET_REF="${INPUT_WORKFLOW_REF}"
|
||||
elif [[ "${CALLER_REPOSITORY}" == "${WORKFLOW_REPOSITORY}" ]]; then
|
||||
TARGET_REF="${CURRENT_SHA}"
|
||||
elif [[ "${WORKFLOW_CONTEXT_REF}" == "${WORKFLOW_REPOSITORY}/"* ]] && [[ "${WORKFLOW_CONTEXT_REF}" == *"@"* ]]; then
|
||||
TARGET_REF="${WORKFLOW_CONTEXT_REF##*@}"
|
||||
else
|
||||
echo "Failed to infer workflow ref from github.workflow_ref=${WORKFLOW_CONTEXT_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${TARGET_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||
echo "value=${TARGET_REF}" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REMOTE_URL="https://github.com/${WORKFLOW_REPOSITORY}.git"
|
||||
if [[ "${TARGET_REF}" == refs/* ]]; then
|
||||
if [[ "${TARGET_REF}" == refs/tags/* ]]; then
|
||||
mapfile -t MATCHES < <(
|
||||
resolve_unique_remote_ref "${REMOTE_URL}" "${TARGET_REF}^{}" "${TARGET_REF}" || true
|
||||
)
|
||||
else
|
||||
mapfile -t MATCHES < <(resolve_unique_remote_ref "${REMOTE_URL}" "${TARGET_REF}" || true)
|
||||
fi
|
||||
else
|
||||
mapfile -t BRANCH_MATCHES < <(
|
||||
resolve_unique_remote_ref "${REMOTE_URL}" "refs/heads/${TARGET_REF}" || true
|
||||
)
|
||||
mapfile -t TAG_MATCHES < <(
|
||||
resolve_unique_remote_ref "${REMOTE_URL}" "refs/tags/${TARGET_REF}^{}" "refs/tags/${TARGET_REF}" || true
|
||||
)
|
||||
|
||||
MATCH_COUNT=$(( ${#BRANCH_MATCHES[@]} + ${#TAG_MATCHES[@]} ))
|
||||
if [[ "${MATCH_COUNT}" -eq 1 ]]; then
|
||||
if [[ "${#BRANCH_MATCHES[@]}" -eq 1 ]]; then
|
||||
MATCHES=("${BRANCH_MATCHES[0]}")
|
||||
else
|
||||
MATCHES=("${TAG_MATCHES[0]}")
|
||||
fi
|
||||
elif [[ "${MATCH_COUNT}" -eq 0 ]]; then
|
||||
MATCHES=()
|
||||
else
|
||||
echo "Workflow ref resolved ambiguously: ${TARGET_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
case "${#MATCHES[@]}" in
|
||||
1)
|
||||
echo "value=${MATCHES[0]}" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
0)
|
||||
echo "Failed to resolve workflow ref: ${TARGET_REF}" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Workflow ref resolved ambiguously: ${TARGET_REF}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Checkout workflow repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.OPENCLAW_REPOSITORY }}
|
||||
ref: ${{ steps.workflow_ref.outputs.value }}
|
||||
path: workflow
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -123,7 +286,7 @@ jobs:
|
||||
env:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare
|
||||
run: |
|
||||
node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \
|
||||
pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
|
||||
--prepare-only \
|
||||
--source-dir source \
|
||||
--output-dir "${OUTPUT_DIR}"
|
||||
@@ -198,6 +361,7 @@ jobs:
|
||||
- name: Resolve runner matrix
|
||||
id: matrix
|
||||
env:
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_MODE: ${{ inputs.mode }}
|
||||
INPUT_UBUNTU_RUNNER: ${{ inputs.ubuntu_runner }}
|
||||
INPUT_WINDOWS_RUNNER: ${{ inputs.windows_runner }}
|
||||
@@ -206,53 +370,30 @@ jobs:
|
||||
VAR_WINDOWS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER }}
|
||||
VAR_MACOS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER }}
|
||||
run: |
|
||||
node <<'NODE' >>"$GITHUB_OUTPUT"
|
||||
const pick = (...values) => values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
|
||||
const lanes = (process.env.INPUT_MODE ?? "both") === "both" ? ["fresh", "upgrade"] : [process.env.INPUT_MODE ?? "both"];
|
||||
const runners = [
|
||||
{
|
||||
os_id: "ubuntu",
|
||||
display_name: "Linux",
|
||||
runner: pick(process.env.INPUT_UBUNTU_RUNNER, process.env.VAR_UBUNTU_RUNNER, "ubuntu-latest"),
|
||||
artifact_name: "linux",
|
||||
},
|
||||
{
|
||||
os_id: "windows",
|
||||
display_name: "Windows",
|
||||
runner: pick(
|
||||
process.env.INPUT_WINDOWS_RUNNER,
|
||||
process.env.VAR_WINDOWS_RUNNER,
|
||||
"blacksmith-32vcpu-windows-2025",
|
||||
),
|
||||
artifact_name: "windows",
|
||||
},
|
||||
{
|
||||
os_id: "macos",
|
||||
display_name: "macOS",
|
||||
runner: pick(process.env.INPUT_MACOS_RUNNER, process.env.VAR_MACOS_RUNNER, "macos-latest-xlarge"),
|
||||
artifact_name: "macos",
|
||||
},
|
||||
];
|
||||
const matrix = {
|
||||
include: runners.flatMap((runner) => lanes.map((lane) => ({ ...runner, lane }))),
|
||||
};
|
||||
process.stdout.write(`value=${JSON.stringify(matrix)}\n`);
|
||||
NODE
|
||||
MATRIX_JSON="$(pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
|
||||
--resolve-matrix \
|
||||
--ref "${INPUT_REF}" \
|
||||
--mode "${INPUT_MODE}" \
|
||||
--ubuntu-runner "${INPUT_UBUNTU_RUNNER}" \
|
||||
--windows-runner "${INPUT_WINDOWS_RUNNER}" \
|
||||
--macos-runner "${INPUT_MACOS_RUNNER}")"
|
||||
echo "value=${MATRIX_JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
cross_os_release_checks:
|
||||
name: "${{ matrix.display_name }} / ${{ matrix.lane }}"
|
||||
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
|
||||
needs: prepare
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Checkout caller release workflow repo
|
||||
- name: Checkout workflow repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ env.OPENCLAW_REPOSITORY }}
|
||||
ref: ${{ needs.prepare.outputs.workflow_ref }}
|
||||
path: workflow
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
@@ -274,7 +415,7 @@ jobs:
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate
|
||||
|
||||
- name: Download baseline artifact
|
||||
if: ${{ matrix.lane == 'upgrade' }}
|
||||
if: ${{ matrix.suite == 'packaged-upgrade' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
|
||||
@@ -286,24 +427,35 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }}
|
||||
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
|
||||
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
|
||||
OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }}
|
||||
OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }}
|
||||
run: |
|
||||
node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \
|
||||
DISCORD_ARGS=()
|
||||
if [[ -n "${OPENCLAW_DISCORD_SMOKE_BOT_TOKEN}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_GUILD_ID}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_CHANNEL_ID}" ]]; then
|
||||
DISCORD_ARGS+=(--run-discord-roundtrip true)
|
||||
fi
|
||||
pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
|
||||
--candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \
|
||||
--candidate-version "${{ needs.prepare.outputs.candidate_version }}" \
|
||||
--source-sha "${{ needs.prepare.outputs.source_sha }}" \
|
||||
--baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \
|
||||
--previous-version "${{ inputs.previous_version }}" \
|
||||
--baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \
|
||||
--provider "${{ inputs.provider }}" \
|
||||
--mode "${{ matrix.lane }}" \
|
||||
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}"
|
||||
--suite "${{ matrix.suite }}" \
|
||||
--ref "${{ inputs.ref }}" \
|
||||
"${DISCORD_ARGS[@]}" \
|
||||
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}"
|
||||
|
||||
- name: Summarize release checks
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}/summary.md
|
||||
SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}/summary.md
|
||||
run: |
|
||||
if [[ -f "${SUMMARY_PATH}" ]]; then
|
||||
cat "${SUMMARY_PATH}" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -315,6 +467,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.lane }}-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}
|
||||
name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.suite }}-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
|
||||
if-no-files-found: error
|
||||
|
||||
572
.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
vendored
Normal file
572
.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
vendored
Normal file
@@ -0,0 +1,572 @@
|
||||
name: OpenClaw Live And E2E Checks (Reusable)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref, tag, or SHA to validate
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
include_repo_e2e:
|
||||
description: Whether to run pnpm test:e2e plus repo-specific extra E2E lanes
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
include_release_path_suites:
|
||||
description: Whether to run the Docker release-path suites
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
include_openwebui:
|
||||
description: Whether to run the Open WebUI Docker smoke
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: Ref, tag, or SHA to validate
|
||||
required: true
|
||||
type: string
|
||||
include_repo_e2e:
|
||||
description: Whether to run pnpm test:e2e
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
include_release_path_suites:
|
||||
description: Whether to run the Docker release-path suites
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
include_openwebui:
|
||||
description: Whether to run the Open WebUI Docker smoke
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
required: false
|
||||
OPENAI_BASE_URL:
|
||||
required: false
|
||||
ANTHROPIC_API_KEY:
|
||||
required: false
|
||||
ANTHROPIC_API_KEY_OLD:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
required: false
|
||||
CEREBRAS_API_KEY:
|
||||
required: false
|
||||
DASHSCOPE_API_KEY:
|
||||
required: false
|
||||
GROQ_API_KEY:
|
||||
required: false
|
||||
KIMI_API_KEY:
|
||||
required: false
|
||||
MODELSTUDIO_API_KEY:
|
||||
required: false
|
||||
MOONSHOT_API_KEY:
|
||||
required: false
|
||||
MISTRAL_API_KEY:
|
||||
required: false
|
||||
MINIMAX_API_KEY:
|
||||
required: false
|
||||
OPENCODE_API_KEY:
|
||||
required: false
|
||||
OPENCODE_ZEN_API_KEY:
|
||||
required: false
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL:
|
||||
required: false
|
||||
OPENCLAW_LIVE_SETUP_TOKEN:
|
||||
required: false
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL:
|
||||
required: false
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE:
|
||||
required: false
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE:
|
||||
required: false
|
||||
GEMINI_API_KEY:
|
||||
required: false
|
||||
GOOGLE_API_KEY:
|
||||
required: false
|
||||
OPENROUTER_API_KEY:
|
||||
required: false
|
||||
QWEN_API_KEY:
|
||||
required: false
|
||||
FAL_KEY:
|
||||
required: false
|
||||
RUNWAY_API_KEY:
|
||||
required: false
|
||||
DEEPGRAM_API_KEY:
|
||||
required: false
|
||||
TOGETHER_API_KEY:
|
||||
required: false
|
||||
VYDRA_API_KEY:
|
||||
required: false
|
||||
XAI_API_KEY:
|
||||
required: false
|
||||
ZAI_API_KEY:
|
||||
required: false
|
||||
Z_AI_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_ACCESS_KEY_ID:
|
||||
required: false
|
||||
BYTEPLUS_SECRET_ACCESS_KEY:
|
||||
required: false
|
||||
CLAUDE_CODE_OAUTH_TOKEN:
|
||||
required: false
|
||||
OPENCLAW_CODEX_AUTH_JSON:
|
||||
required: false
|
||||
OPENCLAW_CODEX_CONFIG_TOML:
|
||||
required: false
|
||||
OPENCLAW_CLAUDE_JSON:
|
||||
required: false
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON:
|
||||
required: false
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON:
|
||||
required: false
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON:
|
||||
required: false
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
|
||||
jobs:
|
||||
validate_release_live_cache:
|
||||
if: inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_LIVE_CACHE_TEST: "1"
|
||||
OPENCLAW_LIVE_TEST: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Validate live cache credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing OPENAI_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify live prompt cache floors
|
||||
run: pnpm test:live:cache
|
||||
|
||||
validate_repo_e2e:
|
||||
if: inputs.include_repo_e2e
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "2"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist for repo E2E
|
||||
run: pnpm build
|
||||
|
||||
- name: Run repo E2E suite
|
||||
run: pnpm test:e2e
|
||||
|
||||
validate_special_e2e:
|
||||
if: inputs.include_repo_e2e || inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite_id: openshell-e2e
|
||||
label: OpenShell repo E2E
|
||||
command: pnpm test:e2e:openshell
|
||||
timeout_minutes: 120
|
||||
requires_repo_e2e: true
|
||||
requires_live_suites: false
|
||||
- suite_id: openai-ws-stream-live-e2e
|
||||
label: OpenAI WebSocket live E2E
|
||||
command: pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts
|
||||
timeout_minutes: 90
|
||||
requires_repo_e2e: false
|
||||
requires_live_suites: true
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_E2E_WORKERS: "1"
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist for special E2E
|
||||
if: |
|
||||
(inputs.include_repo_e2e && matrix.requires_repo_e2e) ||
|
||||
(inputs.include_live_suites && matrix.requires_live_suites)
|
||||
run: pnpm build
|
||||
|
||||
- name: Configure suite-specific env
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.suite_id }}" in
|
||||
openai-ws-stream-live-e2e)
|
||||
echo "OPENAI_LIVE_TEST=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_TEST=1" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate suite credentials
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.suite_id }}" in
|
||||
openai-ws-stream-live-e2e)
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] || {
|
||||
echo "OPENAI_API_KEY is required for the OpenAI WebSocket live E2E suite." >&2
|
||||
exit 1
|
||||
}
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: |
|
||||
(inputs.include_repo_e2e && matrix.requires_repo_e2e) ||
|
||||
(inputs.include_live_suites && matrix.requires_live_suites)
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_e2e:
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite_id: docker-onboard
|
||||
label: Onboarding Docker E2E
|
||||
command: pnpm test:docker:onboard
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-gateway-network
|
||||
label: Gateway Network Docker E2E
|
||||
command: pnpm test:docker:gateway-network
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-mcp-channels
|
||||
label: MCP Channels Docker E2E
|
||||
command: pnpm test:docker:mcp-channels
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-plugins
|
||||
label: Plugins Docker E2E
|
||||
command: pnpm test:docker:plugins
|
||||
timeout_minutes: 75
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-doctor-switch
|
||||
label: Doctor Install Switch Docker E2E
|
||||
command: pnpm test:docker:doctor-switch
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-qr
|
||||
label: QR Import Docker E2E
|
||||
command: pnpm test:docker:qr
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-install-e2e
|
||||
label: Installer Docker E2E
|
||||
command: pnpm test:install:e2e
|
||||
timeout_minutes: 120
|
||||
release_path: true
|
||||
openwebui_only: false
|
||||
- suite_id: docker-openwebui
|
||||
label: Open WebUI Docker E2E
|
||||
command: pnpm test:docker:openwebui
|
||||
timeout_minutes: 75
|
||||
release_path: false
|
||||
openwebui_only: true
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
run: bash scripts/ci-hydrate-live-auth.sh
|
||||
|
||||
- name: Configure suite-specific env
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.suite_id }}" in
|
||||
docker-install-e2e)
|
||||
echo "OPENCLAW_E2E_MODELS=both" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Validate suite credentials
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.suite_id }}" in
|
||||
docker-install-e2e)
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] || {
|
||||
echo "OPENAI_API_KEY is required for installer Docker E2E." >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for installer Docker E2E." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
docker-openwebui)
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] || {
|
||||
echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2
|
||||
exit 1
|
||||
}
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
if: |
|
||||
(inputs.include_release_path_suites && matrix.release_path) ||
|
||||
(inputs.include_openwebui && matrix.openwebui_only)
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_live_provider_suites:
|
||||
if: inputs.include_live_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite_id: live-all
|
||||
label: pnpm test:live
|
||||
command: pnpm test:live
|
||||
timeout_minutes: 180
|
||||
profile_env_only: false
|
||||
- suite_id: live-models-docker
|
||||
label: Docker live models
|
||||
command: pnpm test:docker:live-models
|
||||
timeout_minutes: 120
|
||||
profile_env_only: false
|
||||
- suite_id: live-gateway-docker
|
||||
label: Docker live gateway
|
||||
command: pnpm test:docker:live-gateway
|
||||
timeout_minutes: 120
|
||||
profile_env_only: false
|
||||
- suite_id: live-cli-backend-docker
|
||||
label: Docker live CLI backend
|
||||
command: pnpm test:docker:live-cli-backend
|
||||
timeout_minutes: 120
|
||||
profile_env_only: false
|
||||
- suite_id: live-acp-bind-docker
|
||||
label: Docker live ACP bind
|
||||
command: pnpm test:docker:live-acp-bind
|
||||
timeout_minutes: 120
|
||||
profile_env_only: false
|
||||
- suite_id: live-codex-harness-docker
|
||||
label: Docker live Codex harness
|
||||
command: pnpm test:docker:live-codex-harness
|
||||
timeout_minutes: 120
|
||||
profile_env_only: false
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: ""
|
||||
OPENCLAW_LIVE_VYDRA_VIDEO: "1"
|
||||
OPENCLAW_VITEST_MAX_WORKERS: "2"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
run: bash scripts/ci-hydrate-live-auth.sh
|
||||
|
||||
- name: Configure suite-specific env
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ matrix.profile_env_only }}" == "true" ]]; then
|
||||
echo "OPENCLAW_DOCKER_PROFILE_ENV_ONLY=1" >> "$GITHUB_ENV"
|
||||
fi
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-acp-bind-docker)
|
||||
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Run ${{ matrix.label }}
|
||||
run: ${{ matrix.command }}
|
||||
100
.github/workflows/openclaw-release-checks.yml
vendored
100
.github/workflows/openclaw-release-checks.yml
vendored
@@ -7,6 +7,24 @@ on:
|
||||
description: Existing release tag or current full 40-character main commit SHA to validate (for example v2026.4.12 or 0123456789abcdef0123456789abcdef01234567)
|
||||
required: true
|
||||
type: string
|
||||
provider:
|
||||
description: Provider lane for cross-OS onboarding and the end-to-end agent turn
|
||||
required: false
|
||||
default: openai
|
||||
type: choice
|
||||
options:
|
||||
- openai
|
||||
- anthropic
|
||||
- minimax
|
||||
mode:
|
||||
description: Which cross-OS release lanes to run
|
||||
required: false
|
||||
default: both
|
||||
type: choice
|
||||
options:
|
||||
- fresh
|
||||
- upgrade
|
||||
- both
|
||||
|
||||
concurrency:
|
||||
group: openclaw-release-checks-${{ inputs.ref }}
|
||||
@@ -14,18 +32,18 @@ concurrency:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
|
||||
jobs:
|
||||
# THIS WORKFLOW EXISTS SO RELEASE-TIME LIVE CHECKS CAN RUN WITHOUT BLOCKING npm PUBLISH.
|
||||
# PUT THE SLOWER, EXTERNAL, OR SOMETIMES-FLAKY RELEASE CHECKS HERE INSTEAD OF
|
||||
# RECOUPLING THEM TO openclaw-npm-release.yml.
|
||||
validate_release_live_cache:
|
||||
resolve_target:
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref: ${{ steps.inputs.outputs.ref }}
|
||||
sha: ${{ steps.ref.outputs.sha }}
|
||||
provider: ${{ steps.inputs.outputs.provider }}
|
||||
mode: ${{ steps.inputs.outputs.mode }}
|
||||
steps:
|
||||
- name: Require main workflow ref for release checks
|
||||
env:
|
||||
@@ -73,48 +91,56 @@ jobs:
|
||||
git merge-base --is-ancestor HEAD origin/main
|
||||
fi
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Validate live cache credentials
|
||||
- name: Capture selected inputs
|
||||
id: inputs
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
RELEASE_REF_INPUT: ${{ inputs.ref }}
|
||||
RELEASE_PROVIDER_INPUT: ${{ inputs.provider }}
|
||||
RELEASE_MODE_INPUT: ${{ inputs.mode }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY}" ]]; then
|
||||
echo "Missing OPENAI_API_KEY secret for release checks." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_API_KEY}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for release checks." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# KEEP RELEASE-TIME LIVE COVERAGE HERE SO OPERATORS CAN RUN IT ON DEMAND
|
||||
# WITHOUT MAKING THE PUBLISH PATH WAIT FOR A SLOW OR FLAKY EXTERNAL CHECK.
|
||||
- name: Verify live prompt cache floors
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_LIVE_CACHE_TEST: "1"
|
||||
OPENCLAW_LIVE_TEST: "1"
|
||||
run: pnpm test:live:cache
|
||||
{
|
||||
printf 'ref=%s\n' "$RELEASE_REF_INPUT"
|
||||
printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT"
|
||||
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summarize validated ref
|
||||
env:
|
||||
RELEASE_REF: ${{ inputs.ref }}
|
||||
RELEASE_SHA: ${{ steps.ref.outputs.sha }}
|
||||
RELEASE_PROVIDER: ${{ inputs.provider }}
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
run: |
|
||||
{
|
||||
echo "## Release checks"
|
||||
echo
|
||||
echo "- Requested ref: \`${RELEASE_REF}\`"
|
||||
echo "- Validated SHA: \`${RELEASE_SHA}\`"
|
||||
echo "- Check: \`pnpm test:live:cache\`"
|
||||
echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`"
|
||||
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
|
||||
echo "- This run will execute cross-OS release validation plus the non-Parallels Docker/live/openwebui coverage from the CI migration plan."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
cross_os_release_checks:
|
||||
needs: [resolve_target]
|
||||
permissions: read-all
|
||||
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
provider: ${{ needs.resolve_target.outputs.provider }}
|
||||
mode: ${{ needs.resolve_target.outputs.mode }}
|
||||
secrets: inherit
|
||||
|
||||
live_and_e2e_release_checks:
|
||||
needs: [resolve_target]
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
include_repo_e2e: true
|
||||
include_release_path_suites: true
|
||||
include_openwebui: true
|
||||
include_live_suites: true
|
||||
secrets: inherit
|
||||
|
||||
29
.github/workflows/openclaw-scheduled-live-checks.yml
vendored
Normal file
29
.github/workflows/openclaw-scheduled-live-checks.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: OpenClaw Scheduled Live And E2E Checks
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: openclaw-scheduled-live-checks-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
live_and_openwebui_checks:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
include_repo_e2e: true
|
||||
include_release_path_suites: false
|
||||
include_openwebui: true
|
||||
include_live_suites: true
|
||||
secrets: inherit
|
||||
9
.github/workflows/parity-gate.yml
vendored
9
.github/workflows/parity-gate.yml
vendored
@@ -33,6 +33,13 @@ 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: ""
|
||||
@@ -60,6 +67,7 @@ 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
|
||||
@@ -69,6 +77,7 @@ 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,6 +14,9 @@ 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' }}
|
||||
@@ -32,7 +35,7 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # 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,6 +6,9 @@ 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' }}
|
||||
|
||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -4,8 +4,24 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
|
||||
|
||||
## 2026.4.15
|
||||
|
||||
### Changes
|
||||
|
||||
- Anthropic/models: default Anthropic selections, `opus` aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.
|
||||
- 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.
|
||||
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
|
||||
- Memory/LanceDB: add cloud storage support to `memory-lancedb` so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.
|
||||
- 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
|
||||
|
||||
- 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)
|
||||
@@ -24,19 +40,33 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
|
||||
## 2026.4.15-beta.1
|
||||
|
||||
### Changes
|
||||
|
||||
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
|
||||
- Memory/LanceDB: add cloud storage support to `memory-lancedb` so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.
|
||||
- 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.
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
- Codex/harness: auto-enable the Codex plugin when `codex` is selected as an embedded agent harness runtime, including forced default, per-agent, and `OPENCLAW_AGENT_RUNTIME` paths. (#67474) Thanks @duqaXxX.
|
||||
- OpenAI Codex/CLI: keep resumed `codex exec resume` runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported `--skip-git-repo-check` resume arg plus Codex's native `sandbox_mode="workspace-write"` config override. (#67666) Thanks @plgonzalezrx8.
|
||||
- Codex/app-server: parse Desktop-originated app-server user agents such as `Codex Desktop/0.118.0`, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf.
|
||||
- Cron/announce delivery: keep isolated announce `NO_REPLY` stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale `NO_REPLY` text. (#65016) Thanks @BKF-Gitty.
|
||||
- Sessions/Codex: skip redundant `delivery-mirror` transcript appends only when the latest assistant message has the same visible text, preventing duplicate visible replies on Codex-backed turns without suppressing repeated answers across turns. (#67185) Thanks @andyylin.
|
||||
- Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT.
|
||||
- BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept `updated-message` webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine.
|
||||
- Agents/skills: sort prompt-facing `available_skills` entries by skill name after merging sources so `skills.load.extraDirs` order no longer changes prompt-cache prefixes. (#64198) Thanks @Bartok9.
|
||||
- Agents/OpenAI Responses: add `models.providers.*.models.*.compat.supportsPromptCacheKey` so OpenAI-compatible proxies that forward `prompt_cache_key` can keep prompt caching enabled while incompatible endpoints can still force stripping. (#67427) Thanks @damselem.
|
||||
- Agents/context engines: keep loop-hook and final `afterTurn` prompt-cache touch metadata aligned with the current assistant turn so cache-aware context engines retain accurate cache TTL state during tool loops. (#67767) thanks @jalehman.
|
||||
- Memory/dreaming: strip AI-facing inbound metadata envelopes from session-corpus user turns before normalization so REM topic extraction sees the user's actual message text, including array-shaped split envelopes. (#66548) Thanks @zqchris.
|
||||
- Agents/errors: detect standalone Cloudflare/CDN HTML challenge pages before transport DNS classification so provider block pages no longer appear as local DNS lookup failures. (#67704) Thanks @chris-yyau.
|
||||
- Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)
|
||||
- CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)
|
||||
- CLI/update: prune stale packaged `dist` chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.
|
||||
@@ -68,7 +98,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so `.epub` and `.mobi` uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-`text/plain` coercion. (#66877) Thanks @martinfrancois.
|
||||
- Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when `commands.native` and `commands.nativeSkills` stay on `auto`. (#66843) Thanks @kashevk0.
|
||||
- OpenRouter/Qwen3: parse `reasoning_details` stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.
|
||||
- fix(bluebubbles): replay missed webhook messages after gateway restart via a persistent per-account cursor and `/api/v1/message/query?after=<ts>` pass, so messages delivered while the gateway was down no longer disappear. Uses the existing `processMessage` path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.
|
||||
- BlueBubbles/catchup: replay missed webhook messages after gateway restart via a persistent per-account cursor and `/api/v1/message/query?after=<ts>` pass, so messages delivered while the gateway was down no longer disappear. Uses the existing `processMessage` path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.
|
||||
- Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.
|
||||
- Audio/self-hosted STT: restore `models.providers.*.request.allowPrivateNetwork` for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.
|
||||
- Auto-reply/media: allow workspace-rooted absolute media paths in auto-reply send flows so valid local media references no longer fail path validation. (#66689)
|
||||
@@ -80,17 +110,9 @@ 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
|
||||
|
||||
@@ -436,6 +458,7 @@ 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
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Welcome to the lobster tank! 🦞
|
||||
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
|
||||
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
|
||||
|
||||
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
|
||||
- **Josh Lehman** - Compaction, Context Engine
|
||||
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
|
||||
|
||||
- **Radek Sienkiewicz** - Docs, Control UI
|
||||
|
||||
116
appcast.xml
116
appcast.xml
@@ -2,6 +2,122 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.4.15</title>
|
||||
<pubDate>Thu, 16 Apr 2026 23:33:29 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026041590</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.15</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Anthropic/models: default Anthropic selections, <code>opus</code> aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.</li>
|
||||
<li>Google/TTS: add Gemini text-to-speech support to the bundled <code>google</code> plugin, including provider registration, voice selection, WAV reply output, PCM telephony output, and setup/docs guidance. (#67515) Thanks @barronlroth.</li>
|
||||
<li>Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new <code>models.authStatus</code> gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.</li>
|
||||
<li>Memory/LanceDB: add cloud storage support to <code>memory-lancedb</code> so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.</li>
|
||||
<li>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.</li>
|
||||
<li>Agents/local models: add experimental <code>agents.defaults.experimental.localModelLean: true</code> to drop heavyweight default tools like <code>browser</code>, <code>cron</code>, and <code>message</code>, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.</li>
|
||||
<li>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.</li>
|
||||
<li>QA/Matrix: split Matrix live QA into a source-linked <code>qa-matrix</code> runner and keep repo-private <code>qa-*</code> surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.</li>
|
||||
<li>Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gateway/tools: anchor trusted local <code>MEDIA:</code> 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 (<code>400 invalid_request_error</code> 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)</li>
|
||||
<li>Agents/replay recovery: classify the provider wording <code>401 input item ID does not belong to this connection</code> as replay-invalid, so users get the existing <code>/new</code> session reset guidance instead of a raw 401-style failure. (#66475) Thanks @dallylee.</li>
|
||||
<li>Gateway/webchat: enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.</li>
|
||||
<li>Matrix/pairing: block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.</li>
|
||||
<li>Docker/build: verify <code>@matrix-org/matrix-sdk-crypto-nodejs</code> native bindings with <code>find</code> under <code>node_modules</code> instead of a hardcoded <code>.pnpm/...</code> path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.</li>
|
||||
<li>Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring <code>channels.matrix.password</code>, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.</li>
|
||||
<li>Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with <code>NO_REPLY</code> so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.</li>
|
||||
<li>Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so <code>OPENCLAW_BUNDLED_PLUGINS_DIR</code> flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.</li>
|
||||
<li>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.</li>
|
||||
<li>Agents/context + Memory: trim default startup/skills prompt budgets, cap <code>memory_get</code> 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.</li>
|
||||
<li>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.</li>
|
||||
<li>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.</li>
|
||||
<li>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 <code>agents.defaults.contextTokens</code> is the real limit. (#66236) Thanks @ImLukeF.</li>
|
||||
<li>Dreaming/memory-core: change the default <code>dreaming.storage.mode</code> from <code>inline</code> to <code>separate</code> so Dreaming phase blocks (<code>## Light Sleep</code>, <code>## REM Sleep</code>) land in <code>memory/dreaming/{phase}/YYYY-MM-DD.md</code> instead of being injected into <code>memory/YYYY-MM-DD.md</code>. 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 <code>plugins.entries.memory-core.config.dreaming.storage.mode: "inline"</code>. (#66412) Thanks @mjamiv.</li>
|
||||
<li>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.</li>
|
||||
<li>Dashboard: constrain exec approval modal overflow on desktop so long command content no longer pushes action buttons out of view. (#67082) Thanks @Ziy1-Tan.</li>
|
||||
<li>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.</li>
|
||||
<li>Discord/tool-call text: strip standalone Gemma-style <code><function>...</function></code> tool-call payloads from visible assistant text without truncating prose examples or trailing replies. (#67318) Thanks @joelnishanth.</li>
|
||||
<li>WhatsApp/web-session: drain the pending per-auth creds save queue before reopening sockets so reconnect-time auth bootstrap no longer races in-flight <code>creds.json</code> writes and falsely restores from backup. (#67464) Thanks @neeravmakwana.</li>
|
||||
<li>BlueBubbles/catchup: add a per-message retry ceiling (<code>catchup.maxFailureRetries</code>, default 10) so a persistently-failing message with a malformed payload no longer wedges the catchup cursor forever. After N consecutive <code>processMessage</code> 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.</li>
|
||||
<li>Ollama/chat: strip the <code>ollama/</code> provider prefix from Ollama chat request model ids so configured refs like <code>ollama/qwen3:14b-q8_0</code> stop 404ing against the Ollama API. (#67457) Thanks @suboss87.</li>
|
||||
<li>Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so <code>~/...</code> host edit/write operations stop failing or reading back the wrong file when <code>OPENCLAW_HOME</code> differs. (#62804) Thanks @stainlu.</li>
|
||||
<li>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 <code>[[tts:speed=1.2]]</code> stop silently landing on the wrong provider. (#62846) Thanks @stainlu.</li>
|
||||
<li>OpenAI Codex/models: normalize stale native transport metadata in both runtime resolution and discovery/listing so legacy <code>openai-codex</code> rows with missing <code>api</code> or <code>https://chatgpt.com/backend-api/v1</code> 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)</li>
|
||||
<li>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.</li>
|
||||
<li>Gateway/skills: bump the cached skills-snapshot version whenever a config write touches <code>skills.*</code> (for example <code>skills.allowBundled</code>, <code>skills.entries.<id>.enabled</code>, or <code>skills.profile</code>). Existing agent sessions persist a <code>skillsSnapshot</code> in <code>sessions.json</code> 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 <code>Tool <name> not found</code> loops that ran until the embedded-run timeout. (#67401) Thanks @xantorres.</li>
|
||||
<li>Agents/tool-loop: enable the unknown-tool stream guard by default. Previously <code>resolveUnknownToolGuardThreshold</code> returned <code>undefined</code> unless <code>tools.loopDetection.enabled</code> was explicitly set to <code>true</code>, which left the protection off in the default configuration. A hallucinated or removed tool (for example <code>himalaya</code> after it was dropped from <code>skills.allowBundled</code>) 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 <code>tools.loopDetection.enabled</code> and still accepts <code>tools.loopDetection.unknownToolThreshold</code> as a per-run override (default 10). (#67401) Thanks @xantorres.</li>
|
||||
<li>TUI/streaming: add a client-side streaming watchdog to <code>tui-event-handlers</code> so the <code>streaming · Xm Ys</code> activity indicator resets to <code>idle</code> after 30s of delta silence on the active run. Guards against lost or late <code>state: "final"</code> chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on <code>streaming</code> 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 <code>streamingWatchdogMs</code> context option (set to <code>0</code> to disable), and the handler now exposes a <code>dispose()</code> that clears the pending timer on shutdown. (#67401) Thanks @xantorres.</li>
|
||||
<li>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 <code>(baseUrl, modelKey, contextLength)</code> 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 <code>preload failed</code> 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.</li>
|
||||
<li>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 <code>...toolresult1</code> during compaction and retry flows. (#67620) Thanks @stainlu.</li>
|
||||
<li>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</li>
|
||||
<li>Codex/harness: auto-enable the Codex plugin when <code>codex</code> is selected as an embedded agent harness runtime, including forced default, per-agent, and <code>OPENCLAW_AGENT_RUNTIME</code> paths. (#67474) Thanks @duqaXxX.</li>
|
||||
<li>OpenAI Codex/CLI: keep resumed <code>codex exec resume</code> runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported <code>--skip-git-repo-check</code> resume arg plus Codex's native <code>sandbox_mode="workspace-write"</code> config override. (#67666) Thanks @plgonzalezrx8.</li>
|
||||
<li>Codex/app-server: parse Desktop-originated app-server user agents such as <code>Codex Desktop/0.118.0</code>, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf.</li>
|
||||
<li>Cron/announce delivery: keep isolated announce <code>NO_REPLY</code> stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale <code>NO_REPLY</code> text. (#65016) Thanks @BKF-Gitty.</li>
|
||||
<li>Sessions/Codex: skip redundant <code>delivery-mirror</code> transcript appends only when the latest assistant message has the same visible text, preventing duplicate visible replies on Codex-backed turns without suppressing repeated answers across turns. (#67185) Thanks @andyylin.</li>
|
||||
<li>Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT.</li>
|
||||
<li>BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept <code>updated-message</code> webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine.</li>
|
||||
<li>Agents/skills: sort prompt-facing <code>available_skills</code> entries by skill name after merging sources so <code>skills.load.extraDirs</code> order no longer changes prompt-cache prefixes. (#64198) Thanks @Bartok9.</li>
|
||||
<li>Agents/OpenAI Responses: add <code>models.providers.*.models.*.compat.supportsPromptCacheKey</code> so OpenAI-compatible proxies that forward <code>prompt_cache_key</code> can keep prompt caching enabled while incompatible endpoints can still force stripping. (#67427) Thanks @damselem.</li>
|
||||
<li>Agents/context engines: keep loop-hook and final <code>afterTurn</code> prompt-cache touch metadata aligned with the current assistant turn so cache-aware context engines retain accurate cache TTL state during tool loops. (#67767) thanks @jalehman.</li>
|
||||
<li>Memory/dreaming: strip AI-facing inbound metadata envelopes from session-corpus user turns before normalization so REM topic extraction sees the user's actual message text, including array-shaped split envelopes. (#66548) Thanks @zqchris.</li>
|
||||
<li>Agents/errors: detect standalone Cloudflare/CDN HTML challenge pages before transport DNS classification so provider block pages no longer appear as local DNS lookup failures. (#67704) Thanks @chris-yyau.</li>
|
||||
<li>Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)</li>
|
||||
<li>CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)</li>
|
||||
<li>CLI/update: prune stale packaged <code>dist</code> chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.</li>
|
||||
<li>Onboarding/CLI: fix channel-selection crashes on globally installed CLI setups during onboarding. (#66736)</li>
|
||||
<li>Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.</li>
|
||||
<li>Memory-core/QMD <code>memory_get</code>: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (<code>MEMORY.md</code>, <code>memory.md</code>, <code>DREAMS.md</code>, <code>dreams.md</code>, <code>memory/**</code>) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses <code>read</code> tool-policy denials. (#66026) Thanks @eleqtrizit.</li>
|
||||
<li>Cron/agents: forward embedded-run tool policy and internal event params into the attempt layer so <code>--tools</code> allowlists, cron-owned message-tool suppression, explicit message targeting, and command-path internal events all take effect at runtime again. (#62675) Thanks @hexsprite.</li>
|
||||
<li>Setup/providers: guard preferred-provider lookup during setup so malformed plugin metadata with a missing provider id no longer crashes the wizard with <code>Cannot read properties of undefined (reading 'trim')</code>. (#66649) Thanks @Tianworld.</li>
|
||||
<li>Matrix/security: normalize sandboxed profile avatar params, preserve <code>mxc://</code> avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.</li>
|
||||
<li>Telegram/documents: drop leaked binary caption bytes from inbound Telegram text handling so document uploads like <code>.mobi</code> or <code>.epub</code> no longer explode prompt token counts. (#66663) Thanks @joelnishanth.</li>
|
||||
<li>Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via <code>getResolvedAuth()</code>, mirroring the WebSocket path, so a secret rotated through <code>secrets.reload</code> or config hot-reload stops authenticating on <code>/v1/*</code>, <code>/tools/invoke</code>, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps.</li>
|
||||
<li>Agents/compaction: cap the compaction reserve-token floor to the model context window so small-context local models (e.g. Ollama with 16K tokens) no longer trigger context-overflow errors or infinite compaction loops on every prompt. (#65671) Thanks @openperf.</li>
|
||||
<li>Agents/OpenAI Responses: classify the exact <code>Unknown error (no error details in response)</code> transport failure as failover reason <code>unknown</code> so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer.</li>
|
||||
<li>Models/probe: surface invalid-model probe failures as <code>format</code> instead of <code>unknown</code> in <code>models list --probe</code>, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi.</li>
|
||||
<li>Agents/failover: classify OpenAI-compatible <code>finish_reason: network_error</code> stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.</li>
|
||||
<li>Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.</li>
|
||||
<li>Slack/native commands: fix option menus for slash commands such as <code>/verbose</code> when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared <code>openclaw_cmdarg*</code> listener. Thanks @Wangmerlyn.</li>
|
||||
<li>Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing <code>encryptKey</code> and blank callback tokens — refuse to start the webhook transport without an <code>encryptKey</code>, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.</li>
|
||||
<li>Agents/workspace files: route <code>agents.files.get</code>, <code>agents.files.set</code>, and workspace listing through the shared <code>fs-safe</code> helpers (<code>openFileWithinRoot</code>/<code>readFileWithinRoot</code>/<code>writeFileWithinRoot</code>), reject symlink aliases for allowlisted agent files, and have <code>fs-safe</code> resolve opened-file real paths from the file descriptor before falling back to path-based <code>realpath</code> so a symlink swap between <code>open</code> and <code>realpath</code> can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.</li>
|
||||
<li>Gateway/MCP loopback: switch the <code>/mcp</code> bearer comparison from plain <code>!==</code> to constant-time <code>safeEqualSecret</code> (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via <code>checkBrowserOrigin</code> before the auth gate runs. Loopback origins (<code>127.0.0.1:*</code>, <code>localhost:*</code>, same-origin) still go through, including the <code>localhost</code>↔<code>127.0.0.1</code> host mismatch that browsers flag as <code>Sec-Fetch-Site: cross-site</code>. (#66665) Thanks @eleqtrizit.</li>
|
||||
<li>Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit.</li>
|
||||
<li>Agents/fallback: preserve the original prompt body on model fallback retries with session history so the retrying model keeps the active task instead of only seeing a generic continue message. (#66029) Thanks @WuKongAI-CMU.</li>
|
||||
<li>Reply/secrets: resolve active reply channel/account SecretRefs before reply-run message-action discovery so channel token SecretRefs (for example Discord) do not degrade into discovery-time unresolved-secret failures. (#66796) Thanks @joshavant.</li>
|
||||
<li>Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid <code>max_tokens</code> values no longer reach the provider API. (#66664) thanks @jalehman</li>
|
||||
<li>Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman.</li>
|
||||
<li>BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine.</li>
|
||||
<li>Secrets/plugins/status: align SecretRef inspect-vs-strict handling across plugin preload, read-only status/agents surfaces, and runtime auth paths so unresolved refs no longer crash read-only CLI flows while runtime-required non-env refs stay strict. (#66818) Thanks @joshavant.</li>
|
||||
<li>Memory/dreaming: stop ordinary transcripts that merely quote the dream-diary prompt from being classified as internal dreaming runs and silently dropped from session recall ingestion. (#66852) Thanks @gumadeiras.</li>
|
||||
<li>Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so <code>.epub</code> and <code>.mobi</code> uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-<code>text/plain</code> coercion. (#66877) Thanks @martinfrancois.</li>
|
||||
<li>Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when <code>commands.native</code> and <code>commands.nativeSkills</code> stay on <code>auto</code>. (#66843) Thanks @kashevk0.</li>
|
||||
<li>OpenRouter/Qwen3: parse <code>reasoning_details</code> stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.</li>
|
||||
<li>BlueBubbles/catchup: replay missed webhook messages after gateway restart via a persistent per-account cursor and <code>/api/v1/message/query?after=<ts></code> pass, so messages delivered while the gateway was down no longer disappear. Uses the existing <code>processMessage</code> path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.</li>
|
||||
<li>Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.</li>
|
||||
<li>Audio/self-hosted STT: restore <code>models.providers.*.request.allowPrivateNetwork</code> for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.</li>
|
||||
<li>Auto-reply/media: allow workspace-rooted absolute media paths in auto-reply send flows so valid local media references no longer fail path validation. (#66689)</li>
|
||||
<li>WhatsApp/Baileys media upload: harden encrypted upload handling so large outbound media sends avoid buffer spikes and reliability regressions. (#65966) Thanks @frankekn.</li>
|
||||
<li>QQBot/cron: guard against undefined <code>event.content</code> in <code>parseFaceTags</code> and <code>filterInternalMarkers</code> so cron-triggered agent turns with no content payload no longer crash with <code>TypeError: Cannot read properties of undefined (reading 'startsWith')</code>. (#66302) Thanks @xinmotlanthua.</li>
|
||||
<li>CLI/plugins: stop <code>--dangerously-force-unsafe-install</code> plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.</li>
|
||||
<li>Claude CLI/sessions: classify <code>No conversation found with session ID</code> as <code>session_expired</code> so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn.</li>
|
||||
<li>Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf.</li>
|
||||
<li>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.</li>
|
||||
<li>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 <code>.csv</code> or <code>.md</code> slip past the host-read guard. (#67047) Thanks @Unayung.</li>
|
||||
<li>Ollama/onboarding: split setup into <code>Cloud + Local</code>, <code>Cloud only</code>, and <code>Local only</code>, support direct <code>OLLAMA_API_KEY</code> cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.</li>
|
||||
<li>Webchat/security: reject remote-host <code>file://</code> URLs in the media embedding path. (#67293) Thanks @pgondhi987.</li>
|
||||
<li>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 <code>dailyCount</code> across days instead of stalling at <code>1</code>. (#67091) Thanks @Bartok9.</li>
|
||||
<li>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 <code>/usr/bin/whoami</code> no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.15/OpenClaw-2026.4.15.zip" length="47501638" type="application/octet-stream" sparkle:edSignature="JUG3cicpJqCQDvp7VYoN6qBuN4Kn4s0+QQFjlMR69OZlwViLdiStPIHa+1vpuoR4miYhJc9knSDVCFzSfQuYCQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.4.14</title>
|
||||
<pubDate>Tue, 14 Apr 2026 14:08:09 +0000</pubDate>
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026041501
|
||||
versionName = "2026.4.15-beta.1"
|
||||
versionCode = 2026041690
|
||||
versionName = "2026.4.16"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -132,38 +133,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() {
|
||||
@@ -350,7 +351,7 @@ class GatewayDiscovery(
|
||||
}
|
||||
|
||||
private fun records(msg: Message?, section: Int): List<Record> {
|
||||
return msg?.getSectionArray(section)?.toList() ?: emptyList()
|
||||
return msg?.getSection(section).orEmpty()
|
||||
}
|
||||
|
||||
private fun keyName(raw: String): String {
|
||||
@@ -426,14 +427,14 @@ class GatewayDiscovery(
|
||||
try {
|
||||
SimpleResolver().apply {
|
||||
setAddress(InetSocketAddress(addr, 53))
|
||||
setTimeout(3)
|
||||
setTimeout(Duration.ofSeconds(3))
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (resolvers.isEmpty()) return null
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(3) }
|
||||
ExtendedResolver(resolvers.toTypedArray()).apply { setTimeout(Duration.ofSeconds(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,12 +157,6 @@ 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,5 +1,9 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.4.16 - 2026-04-17
|
||||
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
## 2026.4.15 - 2026-04-15
|
||||
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.4.15
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.15
|
||||
OPENCLAW_IOS_VERSION = 2026.4.16
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.16
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -1 +1 @@
|
||||
Maintenance update for the current OpenClaw beta release.
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.15"
|
||||
"version": "2026.4.16"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.15-beta.1</string>
|
||||
<string>2026.4.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026041501</string>
|
||||
<string>2026041690</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
4fec95c9ce02dddb4d3021812cf68df8b4cc92c5ba4db35778bb1bfe6fa63021 config-baseline.json
|
||||
aafbb407e62908709e90f750ea0f8274016fcfcbd613394896ff984f967f236e config-baseline.core.json
|
||||
ef83a06633fc001b5b2535566939186ecb49d05cd1a90b40e54cc58d3e6e44e3 config-baseline.channel.json
|
||||
3c87ac2fc4c234348eb88812d1904724d7492890498f101d953bc761da8fdead config-baseline.json
|
||||
eeed6fe659078632d9f95b3350b27103b4aba282d050ff38d3b0953a456d242d config-baseline.core.json
|
||||
99bb34fcf83ba6bb50a3fc11f170bd379bee5728b0938707fc39ebd7638e12eb 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
|
||||
9683f324fae8f455f2b64d7e152a77009941e4c7558521bca2510d8bcf573af9 plugin-sdk-api-baseline.json
|
||||
097bf226e4e857e9296d0851852a2963c6263d176c4c470452d9a8efd36988e5 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -116,6 +116,91 @@ 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
|
||||
|
||||
@@ -62,7 +62,10 @@ That lane provisions a disposable Tuwunel homeserver in Docker, registers
|
||||
temporary driver, SUT, and observer users, creates one private room, then runs
|
||||
the real Matrix plugin inside a QA gateway child. The live transport lane keeps
|
||||
the child config scoped to the transport under test, so Matrix runs without
|
||||
`qa-channel` in the child config.
|
||||
`qa-channel` in the child config. It writes the structured report artifacts and
|
||||
a combined stdout/stderr log into the selected Matrix QA output directory. To
|
||||
capture the outer `scripts/run-node.mjs` build/launcher output too, set
|
||||
`OPENCLAW_RUN_NODE_OUTPUT_LOG=<path>` to a repo-local log file.
|
||||
|
||||
For a transport-real Telegram smoke lane, run:
|
||||
|
||||
@@ -148,6 +151,22 @@ The baseline list should stay broad enough to cover:
|
||||
- repo-reading and docs-reading
|
||||
- one small build task such as Lobster Invaders
|
||||
|
||||
## Provider mock lanes
|
||||
|
||||
`qa suite` has two local provider mock lanes:
|
||||
|
||||
- `mock-openai` is the scenario-aware OpenClaw mock. It remains the default
|
||||
deterministic mock lane for repo-backed QA and parity gates.
|
||||
- `aimock` starts an AIMock-backed provider server for experimental protocol,
|
||||
fixture, record/replay, and chaos coverage. It is additive and does not
|
||||
replace the `mock-openai` scenario dispatcher.
|
||||
|
||||
Provider-lane implementation lives under `extensions/qa-lab/src/providers/`.
|
||||
Each provider owns its defaults, local server startup, gateway model config,
|
||||
auth-profile staging needs, and live/mock capability flags. Shared suite and
|
||||
gateway code should route through the provider registry instead of branching on
|
||||
provider names.
|
||||
|
||||
## Transport adapters
|
||||
|
||||
`qa-lab` owns a generic transport seam for markdown QA scenarios.
|
||||
|
||||
@@ -221,7 +221,7 @@ The bundled OpenAI plugin also registers a default for `codex-cli`:
|
||||
|
||||
- `command: "codex"`
|
||||
- `args: ["exec","--json","--color","never","--sandbox","workspace-write","--skip-git-repo-check"]`
|
||||
- `resumeArgs: ["exec","resume","{sessionId}","--color","never","--sandbox","workspace-write","--skip-git-repo-check"]`
|
||||
- `resumeArgs: ["exec","resume","{sessionId}","-c","sandbox_mode=\"workspace-write\"","--skip-git-repo-check"]`
|
||||
- `output: "jsonl"`
|
||||
- `resumeOutput: "text"`
|
||||
- `modelArg: "--model"`
|
||||
|
||||
@@ -52,6 +52,10 @@ These commands sit beside the main test suites when you need QA-lab realism:
|
||||
gateway workers, up to 64 workers or the selected scenario count. Use
|
||||
`--concurrency <count>` to tune the worker count, or `--concurrency 1` for
|
||||
the older serial lane.
|
||||
- Supports provider modes `live-frontier`, `mock-openai`, and `aimock`.
|
||||
`aimock` starts a local AIMock-backed provider server for experimental
|
||||
fixture and protocol-mock coverage without replacing the scenario-aware
|
||||
`mock-openai` lane.
|
||||
- `pnpm openclaw qa suite --runner multipass`
|
||||
- Runs the same QA suite inside a disposable Multipass Linux VM.
|
||||
- Keeps the same scenario-selection behavior as `qa suite` on the host.
|
||||
@@ -65,6 +69,9 @@ These commands sit beside the main test suites when you need QA-lab realism:
|
||||
`.artifacts/qa-e2e/...`.
|
||||
- `pnpm qa:lab:up`
|
||||
- Starts the Docker-backed QA site for operator-style QA work.
|
||||
- `pnpm openclaw qa aimock`
|
||||
- Starts only the local AIMock provider server for direct protocol smoke
|
||||
testing.
|
||||
- `pnpm openclaw qa matrix`
|
||||
- Runs the Matrix live QA lane against a disposable Docker-backed Tuwunel homeserver.
|
||||
- This QA host is repo/dev-only today. Packaged OpenClaw installs do not ship
|
||||
@@ -74,7 +81,7 @@ These commands sit beside the main test suites when you need QA-lab realism:
|
||||
- Provisions three temporary Matrix users (`driver`, `sut`, `observer`) plus one private room, then starts a QA gateway child with the real Matrix plugin as the SUT transport.
|
||||
- Uses the pinned stable Tuwunel image `ghcr.io/matrix-construct/tuwunel:v1.5.1` by default. Override with `OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE` when you need to test a different image.
|
||||
- Matrix does not expose shared credential-source flags because the lane provisions disposable users locally.
|
||||
- Writes a Matrix QA report, summary, and observed-events artifact under `.artifacts/qa-e2e/...`.
|
||||
- Writes a Matrix QA report, summary, observed-events artifact, and combined stdout/stderr output log under `.artifacts/qa-e2e/...`.
|
||||
- `pnpm openclaw qa telegram`
|
||||
- Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env.
|
||||
- Requires `OPENCLAW_QA_TELEGRAM_GROUP_ID`, `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`, and `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`. The group id must be the numeric Telegram chat id.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Google (Gemini)"
|
||||
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, web search)"
|
||||
summary: "Google Gemini setup (API key + OAuth, image generation, media understanding, TTS, 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), and web search via
|
||||
image generation, media understanding (image/audio/video), text-to-speech, and web search via
|
||||
Gemini Grounding.
|
||||
|
||||
- Provider: `google`
|
||||
@@ -133,6 +133,7 @@ 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 |
|
||||
@@ -233,6 +234,50 @@ 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>
|
||||
|
||||
@@ -15,12 +15,14 @@ title: "Thinking Levels"
|
||||
- low → “think hard”
|
||||
- medium → “think harder”
|
||||
- high → “ultrathink” (max budget)
|
||||
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
|
||||
- adaptive → provider-managed adaptive reasoning budget (supported for Anthropic Claude 4.6 model family)
|
||||
- xhigh → “ultrathink+” (GPT-5.2 + Codex models and Anthropic Claude Opus 4.7 effort)
|
||||
- adaptive → provider-managed adaptive thinking (supported for Anthropic Claude 4.6 and Opus 4.7)
|
||||
- `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`.
|
||||
- `highest`, `max` map to `high`.
|
||||
- Provider notes:
|
||||
- Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set.
|
||||
- Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level.
|
||||
- Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting.
|
||||
- MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format.
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
|
||||
@@ -31,7 +33,7 @@ title: "Thinking Levels"
|
||||
2. Session override (set by sending a directive-only message).
|
||||
3. Per-agent default (`agents.list[].thinkingDefault` in config).
|
||||
4. Global default (`agents.defaults.thinkingDefault` in config).
|
||||
5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
|
||||
5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `off` for Anthropic Claude Opus 4.7 unless explicitly configured, `low` for other reasoning-capable models, `off` otherwise.
|
||||
|
||||
## Setting a session default
|
||||
|
||||
@@ -104,8 +106,9 @@ title: "Thinking Levels"
|
||||
|
||||
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
|
||||
- Picking another level writes the session override immediately via `sessions.patch`; it does not wait for the next send and it is not a one-shot `thinkingOnce` override.
|
||||
- The first option is always `Default (<resolved level>)`, where the resolved default comes from the active session model: `adaptive` for Claude 4.6 on Anthropic/Bedrock, `low` for other reasoning-capable models, `off` otherwise.
|
||||
- The first option is always `Default (<resolved level>)`, where the resolved default comes from the active session model: `adaptive` for Claude 4.6 on Anthropic, `off` for Anthropic Claude Opus 4.7 unless configured, `low` for other reasoning-capable models, `off` otherwise.
|
||||
- The picker stays provider-aware:
|
||||
- most providers show `off | minimal | low | medium | high | adaptive`
|
||||
- Anthropic Claude Opus 4.7 shows `off | minimal | low | medium | high | xhigh | adaptive`
|
||||
- Z.AI shows binary `off | on`
|
||||
- `/think:<level>` still works and updates the same stored session level, so chat directives and the picker stay in sync.
|
||||
|
||||
@@ -9,12 +9,13 @@ title: "Text-to-Speech"
|
||||
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, MiniMax, or OpenAI.
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Google Gemini, 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)
|
||||
@@ -34,9 +35,10 @@ or ElevenLabs.
|
||||
|
||||
## Optional keys
|
||||
|
||||
If you want OpenAI, ElevenLabs, or MiniMax:
|
||||
If you want OpenAI, ElevenLabs, Google Gemini, or MiniMax:
|
||||
|
||||
- `ELEVENLABS_API_KEY` (or `XI_API_KEY`)
|
||||
- `GEMINI_API_KEY` (or `GOOGLE_API_KEY`)
|
||||
- `MINIMAX_API_KEY`
|
||||
- `OPENAI_API_KEY`
|
||||
|
||||
@@ -170,6 +172,32 @@ 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
|
||||
@@ -238,7 +266,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"`, `"microsoft"`, `"minimax"`, or `"openai"` (fallback is automatic).
|
||||
- `provider`: speech provider id such as `"elevenlabs"`, `"google"`, `"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`.
|
||||
@@ -250,7 +278,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`, `MINIMAX_API_KEY`, `OPENAI_API_KEY`).
|
||||
- `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`).
|
||||
- `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`
|
||||
@@ -268,6 +296,10 @@ 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`).
|
||||
@@ -302,9 +334,9 @@ Here you go.
|
||||
|
||||
Available directive keys (when enabled):
|
||||
|
||||
- `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)
|
||||
- `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)
|
||||
- `stability`, `similarityBoost`, `style`, `speed`, `useSpeakerBoost`
|
||||
- `vol` / `volume` (MiniMax volume, 0-10)
|
||||
- `pitch` (MiniMax pitch, -12 to 12)
|
||||
@@ -364,6 +396,7 @@ 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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw ACP runtime backend",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/alibaba-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Alibaba Model Studio video provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -101,11 +101,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-6",
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
@@ -114,16 +114,17 @@ describe("anthropic cli migration", () => {
|
||||
});
|
||||
|
||||
expect(result.profiles).toEqual([]);
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7");
|
||||
expect(result.configPatch).toEqual({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "claude-cli/claude-sonnet-4-6",
|
||||
primary: "claude-cli/claude-opus-4-7",
|
||||
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"claude-cli/claude-opus-4-7": { alias: "Opus" },
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-opus-4-6": { alias: "Opus" },
|
||||
"claude-cli/claude-opus-4-5": {},
|
||||
"claude-cli/claude-sonnet-4-5": {},
|
||||
@@ -147,12 +148,13 @@ describe("anthropic cli migration", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-sonnet-4-6");
|
||||
expect(result.defaultModel).toBe("claude-cli/claude-opus-4-7");
|
||||
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": {},
|
||||
@@ -168,9 +170,9 @@ describe("anthropic cli migration", () => {
|
||||
const result = buildAnthropicCliMigrationResult({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-sonnet-4-6" },
|
||||
model: { primary: "claude-cli/claude-opus-4-7" },
|
||||
models: {
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -180,6 +182,7 @@ 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": {},
|
||||
@@ -217,11 +220,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-6",
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
@@ -297,11 +300,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-6",
|
||||
primary: "anthropic/claude-opus-4-7",
|
||||
fallbacks: ["anthropic/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"anthropic/claude-opus-4-7": { alias: "Opus" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
@@ -315,11 +318,11 @@ describe("anthropic cli migration", () => {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "claude-cli/claude-sonnet-4-6",
|
||||
primary: "claude-cli/claude-opus-4-7",
|
||||
fallbacks: ["claude-cli/claude-opus-4-6", "openai/gpt-5.2"],
|
||||
},
|
||||
models: {
|
||||
"claude-cli/claude-sonnet-4-6": { alias: "Sonnet" },
|
||||
"claude-cli/claude-opus-4-7": { alias: "Opus" },
|
||||
"claude-cli/claude-opus-4-6": { alias: "Opus" },
|
||||
"openai/gpt-5.2": {},
|
||||
},
|
||||
|
||||
@@ -2,9 +2,10 @@ 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-sonnet-4-6`;
|
||||
export const CLAUDE_CLI_DEFAULT_MODEL_REF = `${CLAUDE_CLI_BACKEND_ID}/claude-opus-4-7`;
|
||||
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`,
|
||||
@@ -13,9 +14,11 @@ 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-6";
|
||||
return "anthropic/claude-opus-4-7";
|
||||
}
|
||||
if (aliasKey === "sonnet") {
|
||||
return "anthropic/claude-sonnet-4-6";
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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";
|
||||
@@ -18,6 +22,19 @@ 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 });
|
||||
@@ -129,9 +146,9 @@ describe("anthropic provider replay hooks", () => {
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "claude-cli/claude-sonnet-4-6" },
|
||||
model: { primary: "claude-cli/claude-opus-4-7" },
|
||||
models: {
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
"claude-cli/claude-opus-4-7": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -142,6 +159,7 @@ 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": {},
|
||||
@@ -150,6 +168,58 @@ 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("off");
|
||||
expect(
|
||||
provider.resolveDefaultThinkingLevel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-6",
|
||||
} as never),
|
||||
).toBe("adaptive");
|
||||
expect(
|
||||
provider.supportsXHighThinking?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-7",
|
||||
} as never),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.supportsXHighThinking?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-opus-4-6",
|
||||
} as never),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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-6" },
|
||||
defaultModels: { image: "claude-opus-4-7" },
|
||||
autoPriority: { image: 20 },
|
||||
nativeDocumentInputs: ["pdf"],
|
||||
describeImage: describeImageWithModel,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -38,14 +38,23 @@ import { buildAnthropicReplayPolicy } from "./replay-policy.js";
|
||||
import { wrapAnthropicProviderStream } from "./stream-wrappers.js";
|
||||
|
||||
const PROVIDER_ID = "anthropic";
|
||||
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
|
||||
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 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",
|
||||
@@ -221,6 +230,14 @@ 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,
|
||||
@@ -250,6 +267,14 @@ function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isAnthropicOpus47Model(modelId: string): boolean {
|
||||
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return (
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) ||
|
||||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID)
|
||||
);
|
||||
}
|
||||
|
||||
function matchesAnthropicModernModel(modelId: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||
@@ -372,7 +397,7 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
|
||||
|
||||
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
const providerId = "anthropic";
|
||||
const defaultAnthropicModel = "anthropic/claude-sonnet-4-6";
|
||||
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
|
||||
api.registerCliBackend(buildAnthropicCliBackend());
|
||||
api.registerProvider({
|
||||
id: providerId,
|
||||
@@ -462,11 +487,14 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
|
||||
buildReplayPolicy: buildAnthropicReplayPolicy,
|
||||
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
supportsXHighThinking: ({ modelId }) => isAnthropicOpus47Model(modelId),
|
||||
wrapStreamFn: wrapAnthropicProviderStream,
|
||||
resolveDefaultThinkingLevel: ({ modelId }) =>
|
||||
matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
|
||||
? "adaptive"
|
||||
: undefined,
|
||||
isAnthropicOpus47Model(modelId)
|
||||
? "off"
|
||||
: matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
|
||||
? "adaptive"
|
||||
: undefined,
|
||||
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
|
||||
fetchUsageSnapshot: async (ctx) =>
|
||||
await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/arcee-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Arcee provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
@@ -8,7 +8,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.15-beta.1"
|
||||
"openclaw": ">=2026.4.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -43,10 +43,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
"pluginApi": ">=2026.4.16"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
"openclawVersion": "2026.4.16"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import {
|
||||
downloadBlueBubblesAttachment,
|
||||
fetchBlueBubblesMessageAttachments,
|
||||
sendBlueBubblesAttachment,
|
||||
} from "./attachments.js";
|
||||
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
@@ -769,3 +773,86 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
).rejects.toThrow("chatGuid not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchBlueBubblesMessageAttachments", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
it("returns attachments from the BB API response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
attachments: [
|
||||
{
|
||||
guid: "att-1",
|
||||
mimeType: "image/jpeg",
|
||||
transferName: "photo.jpg",
|
||||
totalBytes: 1024,
|
||||
},
|
||||
{
|
||||
guid: "att-2",
|
||||
mime_type: "image/png",
|
||||
transfer_name: "screenshot.png",
|
||||
total_bytes: 2048,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].guid).toBe("att-1");
|
||||
expect(result[0].mimeType).toBe("image/jpeg");
|
||||
expect(result[1].guid).toBe("att-2");
|
||||
expect(result[1].mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("returns empty array on non-ok HTTP response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when data has no attachments", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: {} }),
|
||||
});
|
||||
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes entries without a guid (downstream download handles filtering)", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: {
|
||||
attachments: [{ mimeType: "image/jpeg" }, { guid: "att-valid", mimeType: "image/png" }],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].guid).toBeUndefined();
|
||||
expect(result[1].guid).toBe("att-valid");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { extractAttachments } from "./monitor-normalize.js";
|
||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||
import {
|
||||
fetchBlueBubblesServerInfo,
|
||||
@@ -26,8 +27,12 @@ import {
|
||||
type SsrFPolicy,
|
||||
} from "./types.js";
|
||||
|
||||
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
|
||||
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy | undefined {
|
||||
// Pass `undefined` (not `{}`) for the non-private case so the non-SSRF fallback path
|
||||
// is used. An empty `{}` policy routes through the SSRF guard, which blocks the
|
||||
// localhost BB deployments that are the most common self-hosted setup. The opt-in
|
||||
// private-network branch keeps the explicit policy. (#64105, #67510)
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
export type BlueBubblesAttachmentOpts = {
|
||||
@@ -95,6 +100,51 @@ function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefine
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch attachment metadata for a message from the BlueBubbles API.
|
||||
*
|
||||
* BlueBubbles sometimes fires the `new-message` webhook before attachment
|
||||
* indexing is complete, so `attachments` arrives as `[]`. This function
|
||||
* GETs the message by GUID and returns whatever attachments the server
|
||||
* has indexed by now. (#65430, #67437)
|
||||
*/
|
||||
export async function fetchBlueBubblesMessageAttachments(
|
||||
messageGuid: string,
|
||||
opts: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
timeoutMs?: number;
|
||||
allowPrivateNetwork?: boolean;
|
||||
},
|
||||
): Promise<BlueBubblesAttachment[]> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl: opts.baseUrl,
|
||||
path: `/api/v1/message/${encodeURIComponent(messageGuid)}`,
|
||||
password: opts.password,
|
||||
});
|
||||
// Pass undefined (not {}) when private network is not opted-in so the
|
||||
// non-SSRF fallback path is used — an empty {} triggers the SSRF-guarded
|
||||
// path which blocks localhost BB servers by default. (#64105)
|
||||
const policy: SsrFPolicy | undefined = opts.allowPrivateNetwork
|
||||
? { allowPrivateNetwork: true }
|
||||
: undefined;
|
||||
const response = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{ method: "GET" },
|
||||
opts.timeoutMs,
|
||||
policy,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const json = (await response.json()) as Record<string, unknown>;
|
||||
const data = json.data as Record<string, unknown> | undefined;
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
return extractAttachments(data);
|
||||
}
|
||||
|
||||
export async function downloadBlueBubblesAttachment(
|
||||
attachment: BlueBubblesAttachment,
|
||||
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
|
||||
|
||||
@@ -428,9 +428,13 @@ 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 () => {
|
||||
@@ -606,6 +610,494 @@ 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,6 +4,7 @@ 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";
|
||||
@@ -21,6 +22,14 @@ 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 = {
|
||||
@@ -28,6 +37,13 @@ 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 = {
|
||||
@@ -35,7 +51,21 @@ 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;
|
||||
@@ -43,7 +73,24 @@ export type BlueBubblesCatchupSummary = {
|
||||
fetchedCount: number;
|
||||
};
|
||||
|
||||
export type BlueBubblesCatchupCursor = { lastSeenMs: number; updatedAt: 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>;
|
||||
};
|
||||
|
||||
function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
|
||||
// Explicit OPENCLAW_STATE_DIR overrides take precedence (including
|
||||
@@ -81,6 +128,26 @@ 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> {
|
||||
@@ -92,18 +159,66 @@ export async function loadBlueBubblesCatchupCursor(
|
||||
if (typeof value.lastSeenMs !== "number" || !Number.isFinite(value.lastSeenMs)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveBlueBubblesCatchupCursor(
|
||||
accountId: string,
|
||||
lastSeenMs: number,
|
||||
failureRetries?: Record<string, number>,
|
||||
): Promise<void> {
|
||||
const filePath = resolveCursorFilePath(accountId);
|
||||
const cursor: BlueBubblesCatchupCursor = { lastSeenMs, updatedAt: Date.now() };
|
||||
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 } : {}),
|
||||
};
|
||||
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;
|
||||
@@ -180,10 +295,15 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,10 +367,11 @@ async function runBlueBubblesCatchupInner(
|
||||
const procFn = deps.processMessageFn ?? processMessage;
|
||||
const accountId = target.account.accountId;
|
||||
|
||||
const { maxAgeMs, perRunLimit, firstRunLookbackMs } = clampCatchupConfig(raw);
|
||||
const { maxAgeMs, perRunLimit, firstRunLookbackMs, maxFailureRetries } = 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
|
||||
@@ -295,6 +416,15 @@ 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,
|
||||
@@ -306,7 +436,9 @@ async function runBlueBubblesCatchupInner(
|
||||
replayed: 0,
|
||||
skippedFromMe: 0,
|
||||
skippedPreCursor: 0,
|
||||
skippedGivenUp: 0,
|
||||
failed: 0,
|
||||
givenUp: 0,
|
||||
cursorBefore,
|
||||
cursorAfter: nowMs,
|
||||
windowStartMs,
|
||||
@@ -320,18 +452,31 @@ async function runBlueBubblesCatchupInner(
|
||||
return summary;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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
|
||||
@@ -353,6 +498,30 @@ 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++;
|
||||
@@ -363,15 +532,62 @@ 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++;
|
||||
if (ts > 0 && (earliestProcessFailureTs === null || ts < earliestProcessFailureTs)) {
|
||||
earliestProcessFailureTs = ts;
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
error?.(`[${accountId}] BlueBubbles catchup: processMessage failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,10 +597,17 @@ 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 `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 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 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
|
||||
@@ -400,14 +623,18 @@ async function runBlueBubblesCatchupInner(
|
||||
nextCursorMs = Math.min(Math.max(latestFetchedTs, cursorBefore ?? windowStartMs), nowMs);
|
||||
}
|
||||
summary.cursorAfter = nextCursorMs;
|
||||
await saveBlueBubblesCatchupCursor(accountId, nextCursorMs).catch((err) => {
|
||||
// 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) => {
|
||||
error?.(`[${accountId}] BlueBubbles catchup: cursor save failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
log?.(
|
||||
`[${accountId}] BlueBubbles catchup: replayed=${summary.replayed} ` +
|
||||
`skipped_fromMe=${summary.skippedFromMe} skipped_preCursor=${summary.skippedPreCursor} ` +
|
||||
`failed=${summary.failed} fetched=${summary.fetchedCount} ` +
|
||||
`skipped_givenUp=${summary.skippedGivenUp} failed=${summary.failed} ` +
|
||||
`given_up=${summary.givenUp} fetched=${summary.fetchedCount} ` +
|
||||
`window_ms=${nowMs - windowStartMs}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -50,6 +50,14 @@ 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();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
_resetBlueBubblesInboundDedupForTest,
|
||||
claimBlueBubblesInboundMessage,
|
||||
resolveBlueBubblesInboundDedupeKey,
|
||||
} from "./inbound-dedupe.js";
|
||||
|
||||
async function claimAndFinalize(guid: string | undefined, accountId: string): Promise<string> {
|
||||
@@ -56,3 +57,38 @@ describe("claimBlueBubblesInboundMessage", () => {
|
||||
expect(await claimAndFinalize("g1", "acc")).toBe("claimed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlueBubblesInboundDedupeKey", () => {
|
||||
it("returns messageId for new-message events", () => {
|
||||
expect(resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" })).toBe("msg-1");
|
||||
});
|
||||
|
||||
it("returns associatedMessageGuid for balloon events", () => {
|
||||
expect(
|
||||
resolveBlueBubblesInboundDedupeKey({
|
||||
messageId: "balloon-1",
|
||||
balloonBundleId: "com.apple.messages.URLBalloonProvider",
|
||||
associatedMessageGuid: "msg-1",
|
||||
}),
|
||||
).toBe("msg-1");
|
||||
});
|
||||
|
||||
it("suffixes key with :updated for updated-message events", () => {
|
||||
expect(
|
||||
resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1", eventType: "updated-message" }),
|
||||
).toBe("msg-1:updated");
|
||||
});
|
||||
|
||||
it("updated-message and new-message for same GUID produce distinct keys", () => {
|
||||
const newKey = resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" });
|
||||
const updatedKey = resolveBlueBubblesInboundDedupeKey({
|
||||
messageId: "msg-1",
|
||||
eventType: "updated-message",
|
||||
});
|
||||
expect(newKey).not.toBe(updatedKey);
|
||||
});
|
||||
|
||||
it("returns undefined when messageId is missing", () => {
|
||||
expect(resolveBlueBubblesInboundDedupeKey({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -33,6 +34,11 @@ 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
|
||||
@@ -40,12 +46,42 @@ 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);
|
||||
return path.join(
|
||||
resolveStateDirFromEnv(),
|
||||
"bluebubbles",
|
||||
"inbound-dedupe",
|
||||
`${safePrefix}__${hash}.json`,
|
||||
);
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
function buildPersistentImpl(): ClaimableDedupe {
|
||||
@@ -100,15 +136,27 @@ function sanitizeGuid(guid: string | undefined | null): string | null {
|
||||
export function resolveBlueBubblesInboundDedupeKey(
|
||||
message: Pick<
|
||||
NormalizedWebhookMessage,
|
||||
"messageId" | "balloonBundleId" | "associatedMessageGuid"
|
||||
"messageId" | "balloonBundleId" | "associatedMessageGuid" | "eventType"
|
||||
>,
|
||||
): string | undefined {
|
||||
const balloonBundleId = message.balloonBundleId?.trim();
|
||||
const associatedMessageGuid = message.associatedMessageGuid?.trim();
|
||||
let base: string | undefined;
|
||||
if (balloonBundleId && associatedMessageGuid) {
|
||||
return associatedMessageGuid;
|
||||
base = associatedMessageGuid;
|
||||
} else {
|
||||
base = message.messageId?.trim() || undefined;
|
||||
}
|
||||
return message.messageId?.trim() || undefined;
|
||||
if (!base) {
|
||||
return undefined;
|
||||
}
|
||||
// `updated-message` events get a distinct key so they are not rejected as
|
||||
// duplicates of the already-committed `new-message` for the same GUID.
|
||||
// This lets attachment-carrying follow-up webhooks through. (#65430, #52277)
|
||||
if (message.eventType === "updated-message") {
|
||||
return `${base}:updated`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export type InboundDedupeClaim =
|
||||
@@ -162,6 +210,18 @@ 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
|
||||
|
||||
@@ -34,7 +34,7 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
|
||||
return parseFiniteNumber(record[key]);
|
||||
}
|
||||
|
||||
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
||||
export function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
||||
const raw = message["attachments"];
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
@@ -477,6 +477,8 @@ export type NormalizedWebhookMessage = {
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
/** Webhook event type preserved for dedup key differentiation. */
|
||||
eventType?: string;
|
||||
};
|
||||
|
||||
export type NormalizedWebhookReaction = {
|
||||
@@ -687,6 +689,7 @@ function extractMessagePayload(payload: Record<string, unknown>): Record<string,
|
||||
|
||||
export function normalizeWebhookMessage(
|
||||
payload: Record<string, unknown>,
|
||||
options?: { eventType?: string },
|
||||
): NormalizedWebhookMessage | null {
|
||||
const message = extractMessagePayload(payload);
|
||||
if (!message) {
|
||||
@@ -774,6 +777,7 @@ export function normalizeWebhookMessage(
|
||||
replyToId: replyMetadata.replyToId,
|
||||
replyToBody: replyMetadata.replyToBody,
|
||||
replyToSender: replyMetadata.replyToSender,
|
||||
eventType: options?.eventType,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import {
|
||||
downloadBlueBubblesAttachment,
|
||||
fetchBlueBubblesMessageAttachments,
|
||||
} from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveBlueBubblesConversationRoute } from "./conversation-route.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
@@ -692,8 +695,52 @@ async function processMessageAfterDedupe(
|
||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
const placeholder = buildMessagePlaceholder(message);
|
||||
let attachments = message.attachments ?? [];
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
|
||||
// BlueBubbles may fire the webhook before attachment indexing is complete,
|
||||
// so the initial `attachments` array can be empty for messages that actually
|
||||
// have media. When the message text is empty (image-only) or this is an
|
||||
// `updated-message` event, wait briefly and re-fetch from the BB API as a
|
||||
// fallback for cases where BB doesn't send a follow-up webhook. (#65430, #67437)
|
||||
// This must run before the !rawBody guard below, otherwise image-only messages
|
||||
// with empty attachments are dropped before the retry can fire.
|
||||
const retryMessageId = message.messageId?.trim();
|
||||
const shouldRetryAttachments =
|
||||
attachments.length === 0 &&
|
||||
retryMessageId &&
|
||||
baseUrl &&
|
||||
password &&
|
||||
(text.length === 0 || message.eventType === "updated-message");
|
||||
if (shouldRetryAttachments) {
|
||||
try {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
|
||||
const fetched = await fetchBlueBubblesMessageAttachments(retryMessageId, {
|
||||
baseUrl,
|
||||
password,
|
||||
timeoutMs: 10_000,
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
if (fetched.length > 0) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`attachment retry found ${fetched.length} attachment(s) for msgId=${message.messageId}`,
|
||||
);
|
||||
attachments = fetched;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`attachment retry failed for msgId=${message.messageId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute placeholder from resolved attachments (may have been updated by retry).
|
||||
const placeholder = buildMessagePlaceholder({ ...message, attachments });
|
||||
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
||||
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
||||
const tapbackContext = resolveTapbackContext(message);
|
||||
@@ -1019,9 +1066,6 @@ async function processMessageAfterDedupe(
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
|
||||
if (isGroup && !message.participants?.length && baseUrl && password) {
|
||||
try {
|
||||
const fetchedParticipants = await fetchBlueBubblesParticipantsForInboundMessage({
|
||||
|
||||
@@ -249,11 +249,22 @@ export async function handleBlueBubblesWebhookRequest(
|
||||
return true;
|
||||
}
|
||||
const reaction = normalizeWebhookReaction(payload);
|
||||
// Normalize the webhook message early so the attachment-update detection
|
||||
// below sees attachments under any supported wrapper format (`payload.data`,
|
||||
// `payload.message`, `payload.data.message`, JSON-string payloads), not just
|
||||
// raw `payload.data.attachments`. (#65430, #67510)
|
||||
const message = reaction ? null : normalizeWebhookMessage(payload, { eventType });
|
||||
// BlueBubbles fires `updated-message` when attachments are indexed after the
|
||||
// initial `new-message` (which may arrive with attachments: []). Let those
|
||||
// through so the agent can ingest the image. (#65430)
|
||||
const isAttachmentUpdate =
|
||||
eventType === "updated-message" && (message?.attachments?.length ?? 0) > 0;
|
||||
if (
|
||||
(eventType === "updated-message" ||
|
||||
eventType === "message-reaction" ||
|
||||
eventType === "reaction") &&
|
||||
!reaction
|
||||
!reaction &&
|
||||
!isAttachmentUpdate
|
||||
) {
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
@@ -261,12 +272,11 @@ export async function handleBlueBubblesWebhookRequest(
|
||||
logVerbose(
|
||||
firstTarget.core,
|
||||
firstTarget.runtime,
|
||||
`webhook ignored ${eventType || "event"} without reaction`,
|
||||
`webhook ignored ${eventType || "event"} (no reaction or attachment update)`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const message = reaction ? null : normalizeWebhookMessage(payload);
|
||||
if (!message && !reaction) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
|
||||
@@ -175,10 +175,18 @@ export async function blueBubblesFetchWithTimeout(
|
||||
await release();
|
||||
}
|
||||
}
|
||||
// Strip `dispatcher` from init — the SSRF guard may have attached a bundled-undici
|
||||
// dispatcher that is incompatible with Node 22+'s built-in undici backing globalThis.fetch().
|
||||
// Passing it through causes a silent TypeError (invalid onRequestStart method).
|
||||
// The SSRF validation already completed upstream in fetchWithSsrFGuard before calling
|
||||
// this function as fetchImpl, so stripping the dispatcher does not weaken security. (#64105)
|
||||
const { dispatcher: _dispatcher, ...safeInit } = (init ?? {}) as RequestInit & {
|
||||
dispatcher?: unknown;
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
return await fetch(url, { ...safeInit, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/browser-plugin",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw browser tool plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"name": "Codex",
|
||||
"description": "Codex app-server harness and Codex-managed GPT model catalog.",
|
||||
"providers": ["codex"],
|
||||
"activation": {
|
||||
"onAgentHarnesses": ["codex"]
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "codex",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/codex",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw Codex harness and model provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -200,10 +200,14 @@ describe("CodexAppServerClient", () => {
|
||||
expect(process.unref).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("reads the Codex version from the app-server user agent", () => {
|
||||
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118.0")).toBe("0.118.0");
|
||||
expect(readCodexVersionFromUserAgent("openclaw/0.118.0 (macOS; test)")).toBe("0.118.0");
|
||||
expect(readCodexVersionFromUserAgent("codex_cli_rs/0.118.1-dev (linux; test)")).toBe(
|
||||
"0.118.1-dev",
|
||||
);
|
||||
expect(readCodexVersionFromUserAgent("Codex Desktop/not-a-version")).toBeUndefined();
|
||||
expect(readCodexVersionFromUserAgent("Codex Desktop/0.118")).toBeUndefined();
|
||||
expect(readCodexVersionFromUserAgent("openclaw/0.118.0abc")).toBeUndefined();
|
||||
expect(readCodexVersionFromUserAgent("missing-version")).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -373,8 +373,11 @@ function assertSupportedCodexAppServerVersion(response: CodexInitializeResponse)
|
||||
|
||||
export function readCodexVersionFromUserAgent(userAgent: string | undefined): string | undefined {
|
||||
// Codex returns `<originator>/<codex-version> ...`; the originator can be
|
||||
// OpenClaw or an env override, so only the slash-delimited version is stable.
|
||||
const match = userAgent?.match(/^[^/\s]+\/(\d+\.\d+\.\d+(?:[-+][^\s()]*)?)/);
|
||||
// OpenClaw, Codex Desktop, or an env override, so only the slash-delimited
|
||||
// version in the leading product field is stable.
|
||||
const match = userAgent?.match(
|
||||
/^[^/]+\/(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)(?:[\s(]|$)/,
|
||||
);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/comfy-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw ComfyUI provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepgram-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Deepgram media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -24,10 +24,10 @@
|
||||
"./index.ts"
|
||||
],
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
"pluginApi": ">=2026.4.16"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
"openclawVersion": "2026.4.16"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
|
||||
6
extensions/discord/account-inspect-api.ts
Normal file
6
extensions/discord/account-inspect-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { inspectDiscordAccount } from "./src/account-inspect.js";
|
||||
|
||||
export function inspectDiscordReadOnlyAccount(cfg: OpenClawConfig, accountId?: string | null) {
|
||||
return inspectDiscordAccount({ cfg, accountId });
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export default defineBundledChannelEntry({
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setDiscordRuntime",
|
||||
},
|
||||
accountInspect: {
|
||||
specifier: "./account-inspect-api.js",
|
||||
exportName: "inspectDiscordReadOnlyAccount",
|
||||
},
|
||||
registerFull(api) {
|
||||
api.on("subagent_spawning", async (event) => {
|
||||
const { handleDiscordSubagentSpawning } = await loadDiscordSubagentHooksModule();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -16,7 +16,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.15-beta.1"
|
||||
"openclaw": ">=2026.4.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -52,10 +52,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
"pluginApi": ">=2026.4.16"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
"openclawVersion": "2026.4.16"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
1
extensions/discord/security-audit-contract-api.ts
Normal file
1
extensions/discord/security-audit-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { collectDiscordSecurityAuditFindings } from "./src/security-audit.js";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { withEnv } from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const handleDiscordMessageActionMock = vi.hoisted(() =>
|
||||
@@ -15,20 +16,22 @@ const { discordMessageActions } = await import("./channel-actions.js");
|
||||
|
||||
describe("discordMessageActions", () => {
|
||||
it("returns no tool actions when no token-sourced Discord accounts are enabled", () => {
|
||||
const discovery = discordMessageActions.describeMessageTool?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
withEnv({ DISCORD_BOT_TOKEN: undefined }, () => {
|
||||
const discovery = discordMessageActions.describeMessageTool?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(discovery).toEqual({
|
||||
actions: [],
|
||||
capabilities: [],
|
||||
schema: null,
|
||||
expect(discovery).toEqual({
|
||||
actions: [],
|
||||
capabilities: [],
|
||||
schema: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { coerceNativeSetting, normalizeAllowFromList } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import {
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
} from "openclaw/plugin-sdk/native-command-config-runtime";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { isDiscordMutableAllowEntry } from "./security-doctor.js";
|
||||
|
||||
function normalizeOptionalString(value: string | null | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function addDiscordNameBasedEntries(params: {
|
||||
target: Set<string>;
|
||||
values: unknown;
|
||||
|
||||
@@ -5,9 +5,14 @@ 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 { 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 {
|
||||
agentCommandFromIngress,
|
||||
getTtsProvider,
|
||||
resolveAgentDir,
|
||||
resolveTtsConfig,
|
||||
resolveTtsPrefsPath,
|
||||
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";
|
||||
@@ -809,6 +814,7 @@ 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,6 @@
|
||||
{
|
||||
"name": "@openclaw/duckduckgo-plugin",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw DuckDuckGo plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"id": "elevenlabs",
|
||||
"enabledByDefault": true,
|
||||
"contracts": {
|
||||
"speechProviders": ["elevenlabs"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/elevenlabs-speech",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw ElevenLabs speech plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/exa-plugin",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Exa plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fal-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw fal provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -13,7 +13,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.15-beta.1"
|
||||
"openclaw": ">=2026.4.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
@@ -44,10 +44,10 @@
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.15-beta.1"
|
||||
"pluginApi": ">=2026.4.16"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.15-beta.1"
|
||||
"openclawVersion": "2026.4.16"
|
||||
},
|
||||
"bundle": {
|
||||
"stageRuntimeDependencies": true
|
||||
|
||||
@@ -1 +1,81 @@
|
||||
export { collectFeishuSecurityAuditFindings } from "./src/security-audit.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: unknown): boolean {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasConfiguredSecretInput(value: unknown): boolean {
|
||||
if (hasNonEmptyString(value)) {
|
||||
return true;
|
||||
}
|
||||
const record = asRecord(value);
|
||||
return (
|
||||
Boolean(record) &&
|
||||
hasNonEmptyString(record?.source) &&
|
||||
hasNonEmptyString(record?.provider) &&
|
||||
hasNonEmptyString(record?.id)
|
||||
);
|
||||
}
|
||||
|
||||
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
|
||||
const channels = asRecord(cfg.channels);
|
||||
const feishu = asRecord(channels?.feishu);
|
||||
if (!feishu || feishu.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseTools = asRecord(feishu.tools);
|
||||
const baseDocEnabled = baseTools?.doc !== false;
|
||||
const baseAppId = hasNonEmptyString(feishu.appId);
|
||||
const baseAppSecret = hasConfiguredSecretInput(feishu.appSecret);
|
||||
const baseConfigured = baseAppId && baseAppSecret;
|
||||
|
||||
const accounts = asRecord(feishu.accounts);
|
||||
if (!accounts || Object.keys(accounts).length === 0) {
|
||||
return baseDocEnabled && baseConfigured;
|
||||
}
|
||||
|
||||
for (const accountValue of Object.values(accounts)) {
|
||||
const account = asRecord(accountValue) ?? {};
|
||||
if (account.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
const accountTools = asRecord(account.tools);
|
||||
const effectiveTools = accountTools ?? baseTools;
|
||||
const docEnabled = effectiveTools?.doc !== false;
|
||||
if (!docEnabled) {
|
||||
continue;
|
||||
}
|
||||
const accountConfigured =
|
||||
(hasNonEmptyString(account.appId) || baseAppId) &&
|
||||
(hasConfiguredSecretInput(account.appSecret) || baseAppSecret);
|
||||
if (accountConfigured) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collectFeishuSecurityAuditFindings(params: { cfg: OpenClawConfig }) {
|
||||
if (!isFeishuDocToolEnabled(params.cfg)) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
checkId: "channels.feishu.doc_owner_open_id",
|
||||
severity: "warn" as const,
|
||||
title: "Feishu doc create can grant requester permissions",
|
||||
detail:
|
||||
'channels.feishu tools include "doc"; feishu_doc action "create" can grant document access to the trusted requesting Feishu user.',
|
||||
remediation:
|
||||
"Disable channels.feishu.tools.doc when not needed, and restrict tool access for untrusted prompts.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/setup";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { asRecord, hasNonEmptyString } from "./comment-shared.js";
|
||||
|
||||
function isFeishuDocToolEnabled(cfg: OpenClawConfig): boolean {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/firecrawl-plugin",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Firecrawl plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/fireworks-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Fireworks provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/github-copilot-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw GitHub Copilot provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -5,18 +5,19 @@ 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 = MediaUnderstandingProvider & {
|
||||
describeImage: NonNullable<MediaUnderstandingProvider["describeImage"]>;
|
||||
describeImages: NonNullable<MediaUnderstandingProvider["describeImages"]>;
|
||||
transcribeAudio: NonNullable<MediaUnderstandingProvider["transcribeAudio"]>;
|
||||
describeVideo: NonNullable<MediaUnderstandingProvider["describeVideo"]>;
|
||||
};
|
||||
type GoogleMediaUnderstandingProvider = Required<
|
||||
Pick<
|
||||
MediaUnderstandingProvider,
|
||||
"describeImage" | "describeImages" | "transcribeAudio" | "describeVideo"
|
||||
>
|
||||
>;
|
||||
|
||||
async function loadGoogleImageGenerationProvider(): Promise<ImageGenerationProvider> {
|
||||
if (!googleImageGenerationProviderPromise) {
|
||||
@@ -113,6 +114,7 @@ export default definePluginEntry({
|
||||
api.registerImageGenerationProvider(createLazyGoogleImageGenerationProvider());
|
||||
api.registerMediaUnderstandingProvider(createLazyGoogleMediaUnderstandingProvider());
|
||||
api.registerMusicGenerationProvider(buildGoogleMusicGenerationProvider());
|
||||
api.registerSpeechProvider(buildGoogleSpeechProvider());
|
||||
api.registerVideoGenerationProvider(buildGoogleVideoGenerationProvider());
|
||||
api.registerWebSearchProvider(createGeminiWebSearchProvider());
|
||||
},
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"mediaUnderstandingProviders": ["google"],
|
||||
"imageGenerationProviders": ["google"],
|
||||
"musicGenerationProviders": ["google"],
|
||||
"speechProviders": ["google"],
|
||||
"videoGenerationProviders": ["google"],
|
||||
"webSearchProviders": ["gemini"]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-plugin",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describePluginRegistrationContract } from "../../test/helpers/plugins/p
|
||||
|
||||
describePluginRegistrationContract({
|
||||
...pluginRegistrationContractCases.google,
|
||||
speechProviderIds: ["google"],
|
||||
videoGenerationProviderIds: ["google"],
|
||||
webSearchProviderIds: ["gemini"],
|
||||
requireDescribeImages: true,
|
||||
|
||||
248
extensions/google/speech-provider.test.ts
Normal file
248
extensions/google/speech-provider.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
391
extensions/google/speech-provider.ts
Normal file
391
extensions/google/speech-provider.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
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,5 +1,6 @@
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
@@ -12,7 +12,7 @@
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.15-beta.1"
|
||||
"openclaw": ">=2026.4.16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/groq-provider",
|
||||
"version": "2026.4.15-beta.1",
|
||||
"version": "2026.4.16",
|
||||
"private": true,
|
||||
"description": "OpenClaw Groq media-understanding provider",
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user