mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 22:11:38 +08:00
Compare commits
162 Commits
fix/webcha
...
stack/ios-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69c551ac48 | ||
|
|
4ad8bb8630 | ||
|
|
e0fd16fb61 | ||
|
|
64ce1a11d4 | ||
|
|
2d676d6460 | ||
|
|
2e90bc3d7d | ||
|
|
cafb5c8e12 | ||
|
|
253aec92d1 | ||
|
|
78b7a72510 | ||
|
|
7f682a747d | ||
|
|
3fe4c19305 | ||
|
|
627813aba4 | ||
|
|
1ded5cc9a9 | ||
|
|
5f95f46070 | ||
|
|
5b8fc68ea2 | ||
|
|
9830b7c298 | ||
|
|
6d118ab815 | ||
|
|
4aa548cf7d | ||
|
|
4ffe15c6b2 | ||
|
|
2370ea5d1b | ||
|
|
ae29842158 | ||
|
|
b1b41eb443 | ||
|
|
5341b5c71c | ||
|
|
997197c6c9 | ||
|
|
de9031da22 | ||
|
|
75775f2fe6 | ||
|
|
dbccc73d7a | ||
|
|
fe92113472 | ||
|
|
1d7a287cf6 | ||
|
|
094140bdb1 | ||
|
|
b52c9f2575 | ||
|
|
de62ccbf81 | ||
|
|
9a5bfb1fe5 | ||
|
|
0b3bbfec06 | ||
|
|
b34530a05d | ||
|
|
e1503349c3 | ||
|
|
2a888c5703 | ||
|
|
786ff6afca | ||
|
|
2d67c9b2a0 | ||
|
|
a9ec75fe81 | ||
|
|
0566845b71 | ||
|
|
9083a3f2e3 | ||
|
|
85377a2817 | ||
|
|
d45aa68ae8 | ||
|
|
be5de30de5 | ||
|
|
406e7aba75 | ||
|
|
cad06faafe | ||
|
|
a5a7239182 | ||
|
|
a5a6952bf2 | ||
|
|
d28fa50f8b | ||
|
|
5ef04d2822 | ||
|
|
bb5796265b | ||
|
|
7c179f9288 | ||
|
|
d9d604c6ad | ||
|
|
6cdfd2eaaa | ||
|
|
b3b4fd30c3 | ||
|
|
a951ecdd7b | ||
|
|
c6634b4083 | ||
|
|
524fb16619 | ||
|
|
1fdc20a24f | ||
|
|
925da0fe99 | ||
|
|
99ae722e57 | ||
|
|
eb8a8840d6 | ||
|
|
7c6f8bfe73 | ||
|
|
92c4a2a29e | ||
|
|
70ab91500a | ||
|
|
f175a5d6d3 | ||
|
|
02d26ced98 | ||
|
|
99a48aad08 | ||
|
|
8b80848ae9 | ||
|
|
153a4f55db | ||
|
|
578a7a82be | ||
|
|
e6f34b25aa | ||
|
|
17bb87f432 | ||
|
|
85a320de54 | ||
|
|
46b62c53f0 | ||
|
|
ca1b50908f | ||
|
|
05aa16c040 | ||
|
|
2c6616b830 | ||
|
|
80efcb75c7 | ||
|
|
ba50dfaae3 | ||
|
|
04a8f97c57 | ||
|
|
5cba9a6bab | ||
|
|
da6e6fb900 | ||
|
|
805de8537c | ||
|
|
f7f0caa5c7 | ||
|
|
7fd4328854 | ||
|
|
7bad42910b | ||
|
|
f2c37e543e | ||
|
|
806803b7ef | ||
|
|
f212351aed | ||
|
|
6408b7f81c | ||
|
|
1538813096 | ||
|
|
55c128ddc2 | ||
|
|
3ff0cf262d | ||
|
|
a50dd0bb06 | ||
|
|
8b4cdbb21d | ||
|
|
b8181e5944 | ||
|
|
7a8232187b | ||
|
|
1a0036283d | ||
|
|
4fb6da2b32 | ||
|
|
4a59d0ad98 | ||
|
|
d068fc9f9d | ||
|
|
369646a513 | ||
|
|
3460aa4dee | ||
|
|
e290f4ca41 | ||
|
|
884ca65dc7 | ||
|
|
1a52d943ed | ||
|
|
7897ffb72f | ||
|
|
5c18ba6f65 | ||
|
|
25a2fe2bea | ||
|
|
fa4ff5f3d2 | ||
|
|
ac318be405 | ||
|
|
c85bd2646a | ||
|
|
6472e03412 | ||
|
|
24fd6c8278 | ||
|
|
5cffbbda32 | ||
|
|
85d17fd429 | ||
|
|
96d56a9721 | ||
|
|
ffd3ad032a | ||
|
|
8a463af823 | ||
|
|
6bf1abf603 | ||
|
|
3a8133d587 | ||
|
|
8ac924c769 | ||
|
|
2d033d2aa8 | ||
|
|
1ec9673cc5 | ||
|
|
fdb0bf804f | ||
|
|
40f2e2b8a6 | ||
|
|
87977d7a19 | ||
|
|
9f691099db | ||
|
|
e707c97ca6 | ||
|
|
0750fc2de1 | ||
|
|
59567a8c5d | ||
|
|
8ee357fc76 | ||
|
|
9702d94196 | ||
|
|
30ab9b2068 | ||
|
|
5e1a2ea019 | ||
|
|
008e4804a6 | ||
|
|
4c32411bee | ||
|
|
91cdb703bd | ||
|
|
04ac688dff | ||
|
|
b29e913efe | ||
|
|
895abc5a64 | ||
|
|
62582fc088 | ||
|
|
57336203d5 | ||
|
|
1929151103 | ||
|
|
6ab9e00e17 | ||
|
|
2380c1b5fd | ||
|
|
493b560dfd | ||
|
|
1dd77e4106 | ||
|
|
4d52dfe85b | ||
|
|
d380ed710d | ||
|
|
03755f8463 | ||
|
|
7fdbf1202e | ||
|
|
70db52de71 | ||
|
|
15a0455d04 | ||
|
|
d3c637d193 | ||
|
|
0fb3f188b2 | ||
|
|
bf6aa7ca67 | ||
|
|
0fd77c9856 | ||
|
|
f77f1d3800 | ||
|
|
7c90ef7c52 |
@@ -51,6 +51,10 @@ vendor/
|
||||
# Keep the rest of apps/ and vendor/ excluded to avoid a large build context.
|
||||
!apps/shared/
|
||||
!apps/shared/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Sources/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/
|
||||
!apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json
|
||||
!apps/shared/OpenClawKit/Tools/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/
|
||||
!apps/shared/OpenClawKit/Tools/CanvasA2UI/**
|
||||
|
||||
1
.github/actionlint.yaml
vendored
1
.github/actionlint.yaml
vendored
@@ -8,6 +8,7 @@ self-hosted-runner:
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-windows-2025
|
||||
- blacksmith-32vcpu-windows-2025
|
||||
- blacksmith-16vcpu-ubuntu-2404-arm
|
||||
|
||||
# Ignore patterns for known issues
|
||||
|
||||
15
.github/actions/setup-node-env/action.yml
vendored
15
.github/actions/setup-node-env/action.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Setup Node environment
|
||||
description: >
|
||||
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
|
||||
and run pnpm install. Requires actions/checkout to run first.
|
||||
and optionally run pnpm install. Requires actions/checkout to run first.
|
||||
inputs:
|
||||
node-version:
|
||||
description: Node.js version to install.
|
||||
@@ -15,6 +15,14 @@ inputs:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
default: "true"
|
||||
use-sticky-disk:
|
||||
description: Use Blacksmith sticky disks for pnpm store caching.
|
||||
required: false
|
||||
default: "false"
|
||||
install-deps:
|
||||
description: Whether to run pnpm install after environment setup.
|
||||
required: false
|
||||
default: "true"
|
||||
frozen-lockfile:
|
||||
description: Whether to use --frozen-lockfile for install.
|
||||
required: false
|
||||
@@ -40,13 +48,14 @@ runs:
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: true
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: ${{ inputs.pnpm-version }}
|
||||
cache-key-suffix: "node22"
|
||||
use-sticky-disk: ${{ inputs.use-sticky-disk }}
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
@@ -63,10 +72,12 @@ runs:
|
||||
if command -v bun &>/dev/null; then bun -v; fi
|
||||
|
||||
- name: Capture node path
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
run: echo "NODE_BIN=$(dirname "$(node -p "process.execPath")")" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install dependencies
|
||||
if: inputs.install-deps == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CI: "true"
|
||||
|
||||
@@ -9,6 +9,18 @@ inputs:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
default: "node22"
|
||||
use-sticky-disk:
|
||||
description: Use Blacksmith sticky disks instead of actions/cache for pnpm store.
|
||||
required: false
|
||||
default: "false"
|
||||
use-restore-keys:
|
||||
description: Whether to use restore-keys fallback for actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
use-actions-cache:
|
||||
description: Whether to restore/save pnpm store with actions/cache.
|
||||
required: false
|
||||
default: "true"
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@@ -38,7 +50,22 @@ runs:
|
||||
shell: bash
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore pnpm store cache
|
||||
- name: Mount pnpm store sticky disk
|
||||
if: inputs.use-sticky-disk == 'true'
|
||||
uses: useblacksmith/stickydisk@v1
|
||||
with:
|
||||
key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ inputs.cache-key-suffix }}
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
|
||||
- name: Restore pnpm store cache (exact key only)
|
||||
if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Restore pnpm store cache (with fallback keys)
|
||||
if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys == 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
|
||||
164
.github/workflows/ci.yml
vendored
164
.github/workflows/ci.yml
vendored
@@ -32,12 +32,13 @@ jobs:
|
||||
# Push to main keeps broad coverage.
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
run_android: ${{ steps.scope.outputs.run_android }}
|
||||
run_windows: ${{ steps.scope.outputs.run_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -57,75 +58,11 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")"
|
||||
if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then
|
||||
# Fail-safe: run broad checks if detection fails.
|
||||
echo "run_node=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_node=false
|
||||
run_macos=false
|
||||
run_android=false
|
||||
has_non_docs=false
|
||||
has_non_native_non_docs=false
|
||||
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
case "$path" in
|
||||
docs/*|*.md|*.mdx)
|
||||
continue
|
||||
;;
|
||||
*)
|
||||
has_non_docs=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
# Generated protocol models are already covered by protocol:check and
|
||||
# should not force the full native macOS lane.
|
||||
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
|
||||
;;
|
||||
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
|
||||
run_macos=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/shared/*)
|
||||
run_android=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc)
|
||||
run_node=true
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml)
|
||||
;;
|
||||
*)
|
||||
has_non_native_non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
# If there are non-doc files outside native app trees, keep Node checks enabled.
|
||||
if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then
|
||||
run_node=true
|
||||
fi
|
||||
|
||||
echo "run_node=${run_node}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT"
|
||||
echo "run_android=${run_android}" >> "$GITHUB_OUTPUT"
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||
build-artifacts:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
@@ -138,6 +75,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
@@ -164,6 +102,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -175,7 +114,7 @@ jobs:
|
||||
run: pnpm release:check
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
@@ -207,6 +146,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
@@ -223,8 +163,8 @@ jobs:
|
||||
# Types, lint, and format check.
|
||||
check:
|
||||
name: "check"
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -236,6 +176,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
@@ -275,6 +216,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Run ${{ matrix.tool }} dead-code scan
|
||||
run: ${{ matrix.command }}
|
||||
@@ -300,6 +242,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
@@ -342,6 +285,8 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
install-deps: "false"
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -385,15 +330,15 @@ jobs:
|
||||
run: pre-commit run --all-files pnpm-audit-prod
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope, build-artifacts, check]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||
runs-on: blacksmith-16vcpu-windows-2025
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true')
|
||||
runs-on: blacksmith-32vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# Keep total concurrency predictable on the 16 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
# Keep total concurrency predictable on the 32 vCPU runner.
|
||||
# Windows shard 2 has shown intermittent instability at 2 workers.
|
||||
OPENCLAW_TEST_WORKERS: 1
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -401,26 +346,36 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runtime: node
|
||||
task: lint
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm lint
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && pnpm test
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: protocol
|
||||
shard_index: 0
|
||||
shard_count: 1
|
||||
command: pnpm protocol:check
|
||||
task: test
|
||||
shard_index: 3
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 4
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 5
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
- runtime: node
|
||||
task: test
|
||||
shard_index: 6
|
||||
shard_count: 6
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -446,31 +401,22 @@ jobs:
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Download dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Verify dist artifact (lint lane)
|
||||
if: matrix.task == 'lint'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -s dist/index.js
|
||||
test -s dist/plugin-sdk/index.js
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
check-latest: false
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
# Sticky disk mount currently retries/fails on every shard and adds ~50s
|
||||
# before install while still yielding zero pnpm store reuse.
|
||||
use-sticky-disk: "false"
|
||||
use-restore-keys: "false"
|
||||
use-actions-cache: "false"
|
||||
|
||||
- name: Runtime versions
|
||||
run: |
|
||||
@@ -489,7 +435,7 @@ jobs:
|
||||
which node
|
||||
node -v
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Configure test shard (Windows)
|
||||
if: matrix.task == 'test'
|
||||
@@ -497,6 +443,10 @@ jobs:
|
||||
echo "OPENCLAW_TEST_SHARDS=${{ matrix.shard_count }}" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=${{ matrix.shard_index }}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build A2UI bundle (Windows)
|
||||
if: matrix.task == 'test'
|
||||
run: pnpm canvas:a2ui:bundle
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
@@ -738,7 +688,7 @@ jobs:
|
||||
PY
|
||||
|
||||
android:
|
||||
needs: [docs-scope, changed-scope, check]
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
|
||||
16
.github/workflows/docker-release.yml
vendored
16
.github/workflows/docker-release.yml
vendored
@@ -34,8 +34,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -92,14 +92,12 @@ jobs:
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:amd64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
@@ -115,8 +113,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -173,14 +171,12 @@ jobs:
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-cache:arm64,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -44,10 +44,14 @@ jobs:
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
use-sticky-disk: "true"
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker build -t openclaw-dockerfile-smoke:local -f Dockerfile .
|
||||
|
||||
3
.github/workflows/sandbox-common-smoke.yml
vendored
3
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -26,6 +26,9 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@v1
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
155
.github/workflows/stale.yml
vendored
Normal file
155
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Mark stale issues and pull requests
|
||||
uses: actions/stale@v9
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
days-before-issue-close: 5
|
||||
days-before-pr-stale: 5
|
||||
days-before-pr-close: 3
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 10000
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
|
||||
lock-closed-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Lock closed issues after 48h of no comments
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const lockAfterHours = 48;
|
||||
const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
|
||||
const perPage = 100;
|
||||
const cutoffMs = Date.now() - lockAfterMs;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: perPage,
|
||||
page,
|
||||
});
|
||||
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) {
|
||||
continue;
|
||||
}
|
||||
if (issue.locked) {
|
||||
continue;
|
||||
}
|
||||
if (!issue.closed_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs)) {
|
||||
continue;
|
||||
}
|
||||
if (closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
const lastActivityMs = Math.max(closedAtMs, lastCommentMs || 0);
|
||||
if (lastActivityMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "resolved",
|
||||
});
|
||||
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@ mise.toml
|
||||
apps/android/.gradle/
|
||||
apps/android/app/build/
|
||||
apps/android/.cxx/
|
||||
apps/android/.kotlin/
|
||||
|
||||
# Bun build artifacts
|
||||
*.bun-build
|
||||
|
||||
422
CHANGELOG.md
422
CHANGELOG.md
@@ -2,207 +2,242 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.3.2 (Unreleased)
|
||||
## 2026.3.3
|
||||
|
||||
### Changes
|
||||
|
||||
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
|
||||
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
|
||||
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
|
||||
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
|
||||
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
|
||||
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
|
||||
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
|
||||
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.
|
||||
- Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
|
||||
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
|
||||
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
|
||||
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
|
||||
- Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
|
||||
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
|
||||
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
|
||||
- Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
|
||||
- Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
|
||||
- Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.
|
||||
- Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.
|
||||
- Memory/QMD index isolation: set `QMD_CONFIG_DIR` alongside `XDG_CONFIG_HOME` so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.
|
||||
- LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.
|
||||
- LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
|
||||
- LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
|
||||
- LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
|
||||
- LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
### Changes
|
||||
|
||||
- Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, `openclaw secrets` planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
|
||||
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
|
||||
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
|
||||
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077.
|
||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
|
||||
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
|
||||
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
|
||||
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
|
||||
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- Feishu/inbound mention normalization: preserve all inbound mention semantics by normalizing Feishu mention placeholders into explicit `<at user_id=\"...\">name</at>` tags (instead of stripping them), improving multi-mention context fidelity in agent prompts while retaining bot/self mention disambiguation. (#30252) Thanks @Lanfei.
|
||||
- Feishu/multi-app mention routing: guard mention detection in multi-bot groups by validating mention display name alongside bot `open_id`, preventing false-positive self-mentions from Feishu WebSocket remapping so only the actually mentioned bot responds under `requireMention`. (#30315) Thanks @teaguexiao.
|
||||
- Feishu/session-memory hook parity: trigger the shared `before_reset` session-memory hook path when Feishu `/new` and `/reset` commands execute so reset flows preserve memory behavior consistent with other channels. (#31437) Thanks @Linux2010.
|
||||
- Feishu/LINE group system prompts: forward per-group `systemPrompt` config into inbound context `GroupSystemPrompt` for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.
|
||||
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
|
||||
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
|
||||
- Voice-call/runtime lifecycle: prevent `EADDRINUSE` loops by resetting failed runtime promises, making webhook `start()` idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
|
||||
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
|
||||
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
|
||||
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
|
||||
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
|
||||
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
|
||||
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
|
||||
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
|
||||
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
|
||||
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
|
||||
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
|
||||
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
|
||||
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
|
||||
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
|
||||
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
|
||||
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
|
||||
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- Gateway/Webchat streaming finalization: flush throttled trailing assistant text before `final` chat events so streaming consumers do not miss tail content, while preserving duplicate suppression and heartbeat/silent lead-fragment guards. (#24856) Thanks @visionik and @vincentkoc.
|
||||
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
|
||||
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
|
||||
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
|
||||
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
|
||||
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @bmendonca3.
|
||||
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
|
||||
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
|
||||
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
- Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.
|
||||
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
|
||||
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
|
||||
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
|
||||
- Feishu/Lark private DM routing: treat inbound `chat_type: "private"` as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
|
||||
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
|
||||
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
|
||||
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
|
||||
- Signal/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
|
||||
- Discord/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
|
||||
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
|
||||
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
|
||||
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
|
||||
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
|
||||
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
|
||||
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
|
||||
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
|
||||
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
|
||||
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
|
||||
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
|
||||
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
|
||||
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
|
||||
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
|
||||
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
|
||||
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
|
||||
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
|
||||
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
|
||||
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
|
||||
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
|
||||
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
|
||||
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
|
||||
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
|
||||
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
|
||||
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
|
||||
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
|
||||
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
|
||||
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
|
||||
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
|
||||
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
|
||||
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
|
||||
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
|
||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
|
||||
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
||||
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
||||
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
||||
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
|
||||
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
|
||||
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
|
||||
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
|
||||
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
|
||||
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
|
||||
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
|
||||
- Gateway/Plugin HTTP hardening: require explicit `auth` for plugin route registration, add route ownership guards for duplicate `path+match` registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
|
||||
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Browser/Extension relay reconnect tolerance: keep `/json/version` and `/cdp` reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
- Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
|
||||
- Browser/Profile defaults: prefer `openclaw` profile over `chrome` in headless/no-sandbox environments unless an explicit `defaultProfile` is configured. (#14944) Thanks @BenediktSchackenberg.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
|
||||
- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
|
||||
- Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- Gateway/WS security: keep plaintext `ws://` loopback-only by default, with explicit break-glass private-network opt-in via `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
|
||||
- Gateway/Subagent TLS pairing: allow authenticated local `gateway-client` backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring `sessions_spawn` with `gateway.tls.enabled=true` in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with `204` to avoid persistent `Processing...` states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
|
||||
- OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit `doctor --deep`) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
|
||||
- Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
|
||||
- Synology Chat/reply delivery: resolve webhook usernames to Chat API `user_id` values for outbound chatbot replies, avoiding mismatches between webhook user IDs and `method=chatbot` recipient IDs in multi-account setups. (#23709) Thanks @druide67.
|
||||
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
|
||||
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
|
||||
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
|
||||
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
|
||||
- Slack/session routing: keep top-level channel messages in one shared session when `replyToMode=off`, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
|
||||
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
|
||||
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
|
||||
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
|
||||
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
|
||||
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
|
||||
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
|
||||
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
|
||||
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
|
||||
- Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
|
||||
- Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (`monitor.account-scope.test.ts`) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
|
||||
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
|
||||
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
|
||||
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
|
||||
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
|
||||
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
|
||||
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
|
||||
- Gateway/Node browser proxy routing: honor `profile` from `browser.request` JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
|
||||
- Gateway/Control UI basePath POST handling: return 405 for `POST` on exact basePath routes (for example `/openclaw`) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
|
||||
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
|
||||
- Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
|
||||
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
|
||||
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
|
||||
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
|
||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
|
||||
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
|
||||
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
|
||||
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @AIflow-Labs.
|
||||
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
|
||||
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
|
||||
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
|
||||
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
|
||||
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
|
||||
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
|
||||
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
|
||||
- Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
|
||||
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
|
||||
- Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
|
||||
- Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
|
||||
- Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)
|
||||
- Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured `LarkApiError` responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)
|
||||
- Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (`contact:contact.base:readonly`) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
- Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
|
||||
- Feishu/Send target prefixes: normalize explicit `group:`/`dm:` send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
|
||||
- Slack/Channel message subscriptions: register explicit `message.channels` and `message.groups` monitor handlers (alongside generic `message`) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
|
||||
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
|
||||
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
|
||||
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
|
||||
- Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
|
||||
- Browser/Extension re-announce reliability: keep relay state in `connecting` when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
|
||||
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
|
||||
- Browser/Profile attach-only override: support `browser.profiles.<name>.attachOnly` (fallback to global `browser.attachOnly`) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
|
||||
- Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file `starttime` with `/proc/<pid>/stat` starttime, so stale `.jsonl.lock` files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
|
||||
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
|
||||
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Gateway/Node dangerous-command parity: include `sms.send` in default onboarding node `denyCommands`, share onboarding deny defaults with the gateway dangerous-command source of truth, and include `sms.send` in phone-control `/phone arm writes` handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
|
||||
- Browser/CDP proxy bypass: force direct loopback agent paths and scoped `NO_PROXY` expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
|
||||
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
|
||||
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
|
||||
- Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (`mtimeMs` + `sizeBytes`), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
|
||||
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
|
||||
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
|
||||
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
|
||||
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
|
||||
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
|
||||
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
|
||||
- Plugins/install diagnostics: reject legacy plugin package shapes without `openclaw.extensions` and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
|
||||
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
|
||||
- Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (`pnpm`, `bun`) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
|
||||
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
|
||||
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
|
||||
- Authentication: classify `permission_error` as `auth_permanent` for profile fallback. (#31324) Thanks @Sid-Qin.
|
||||
- Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (`newText` present and `oldText` absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
|
||||
- Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example `diffs` -> bundled `@openclaw/diffs`), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
|
||||
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
|
||||
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
|
||||
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
|
||||
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
|
||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||
- Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
|
||||
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
|
||||
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
|
||||
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
|
||||
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
|
||||
## 2026.3.1
|
||||
@@ -239,6 +274,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
@@ -249,7 +285,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/Cron: clarify `cron list` output by renaming `Agent` to `Agent ID` and adding a `Model` column for isolated agent-turn jobs. (#26259) Thanks @openperf.
|
||||
- Gateway/Control UI origins: honor `gateway.controlUi.allowedOrigins: ["*"]` wildcard entries (including trimmed values) and lock behavior with regression tests. Landed from contributor PR #31058 by @byungsker. Thanks @byungsker.
|
||||
- Agents/Sessions list transcript paths: handle missing/non-string/relative `sessions.list.path` values and per-agent `{agentId}` templates when deriving `transcriptPath`, so cross-agent session listings resolve to concrete agent session files instead of workspace-relative paths. (#24775) Thanks @martinfrancois.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @Glucksberg and @vincentkoc.
|
||||
- Gateway/Control UI CSP: allow required Google Fonts origins in Control UI CSP. (#29279) Thanks @vincentkoc.
|
||||
- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
|
||||
- Plugins/NPM spec install: fix npm-spec plugin installs when `npm pack` output is empty by detecting newly created `.tgz` archives in the pack directory. (#21039) Thanks @graysurf and @vincentkoc.
|
||||
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
|
||||
@@ -275,12 +311,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Android/Voice screen TTS: stream assistant speech via ElevenLabs WebSocket in Talk Mode, stop cleanly on speaker mute/barge-in, and ignore stale out-of-order stream events. (#29521) Thanks @gregmousseau.
|
||||
- Android/Photos permissions: declare Android 14+ selected-photo access permission (`READ_MEDIA_VISUAL_USER_SELECTED`) and align Android permission/settings paths with current minSdk behavior for more reliable permission state handling.
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959) Thanks @icesword0760.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @Glucksberg.
|
||||
- Slack/User-token resolution: normalize Slack account user-token sourcing through resolved account metadata (`SLACK_USER_TOKEN` env + config) so monitor reads, Slack actions, directory lookups, onboarding allow-from resolution, and capabilities probing consistently use the effective user token. (#28103) Thanks @chilu18.
|
||||
- Feishu/Outbound session routing: stop assuming bare `oc_` identifiers are always group chats, honor explicit `dm:`/`group:` prefixes for `oc_` chat IDs, and default ambiguous bare `oc_` targets to direct routing to avoid DM session misclassification. (#10407) Thanks @Bermudarat.
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798) Thanks @yfge.
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325) Thanks @kcinzgg.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @hou-rong.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @PinoHouse.
|
||||
- Feishu/Mobile video media type: treat inbound `message_type: "media"` as video-equivalent for media key extraction, placeholder inference, and media download resolution so mobile-app video sends ingest correctly. (#25502) Thanks @4ier.
|
||||
- Feishu/Inbound sender fallback: fall back to `sender_id.user_id` when `sender_id.open_id` is missing on inbound events, and use ID-type-aware sender lookup so mobile-delivered messages keep stable sender identity/routing. (#26703) Thanks @NewdlDewdl.
|
||||
- Feishu/Reply context metadata: include inbound `parent_id` and `root_id` as `ReplyToId`/`RootMessageId` in inbound context, and parse interactive-card quote bodies into readable text when fetching replied messages. (#18529) Thanks @qiangu.
|
||||
@@ -341,7 +377,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Matrix/Directory room IDs: preserve original room-ID casing for direct `!roomId` group lookups (without `:server`) so allowlist checks do not fail on case-sensitive IDs. Landed from contributor PR #31201 by @williamos-dev. Thanks @williamos-dev.
|
||||
- Discord/Inbound media fallback: preserve attachment and sticker metadata when Discord CDN fetch/save fails by keeping URL-based media entries in context, with regression coverage for save failures and mixed success/failure ordering. Landed from contributor PR #28906 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Auto-reply/Block reply timeout path: normalize `onBlockReply(...)` execution through `Promise.resolve(...)` before timeout wrapping so mixed sync/async callbacks keep deterministic timeout behavior across strict TypeScript build paths. (#19779) Thanks @dalefrieswthat and @vincentkoc.
|
||||
- Cron/One-shot reschedule re-arm: allow completed `at` jobs to run again when rescheduled to a later time than `lastRunAtMs`, while keeping completed non-rescheduled one-shot jobs inactive. (#28915) Thanks @Glucksberg.
|
||||
- Cron/One-shot reschedule re-arm: allow completed `at` jobs to run again when rescheduled to a later time than `lastRunAtMs`, while keeping completed non-rescheduled one-shot jobs inactive. (#28915) Thanks @arosstale.
|
||||
- Docs/Docker images: clarify the official GHCR image source and tag guidance (`main`, `latest`, `<version>`), and document that `OPENCLAW_IMAGE` skips local image builds but still uses the repo-local compose/setup flow. (#27214, #31180) Fixes #15655. Thanks @ipl31.
|
||||
- Docs/Gateway Docker bind guidance: clarify bridge-network loopback behavior and require bind mode values (`auto`/`loopback`/`lan`/`tailnet`/`custom`) instead of host aliases in `gateway.bind`. (#28001) Thanks @Anandesh-Sharma and @vincentkoc.
|
||||
- Docker/Image base annotations: add OCI labels for base image plus source/documentation/license metadata, include revision/version/created labels in Docker release builds, and document annotation keys/release context in install docs. Fixes #27945. Thanks @vincentkoc.
|
||||
@@ -353,7 +389,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/Ack reactions: add Discord-account-level `ackReactionScope` override and support explicit `off`/`none` values in shared config schemas to disable ack reactions per account. Landed from contributor PR #30400 by @BlueBirdBack. Thanks @BlueBirdBack.
|
||||
- Discord/Forum thread tags: support `appliedTags` on Discord thread-create actions and map to `applied_tags` for forum/media starter posts, with targeted thread-creation regression coverage. Landed from contributor PR #30358 by @pushkarsingh32. Thanks @pushkarsingh32.
|
||||
- Discord/Application ID fallback: parse bot application IDs from token prefixes without numeric precision loss and use token fallback only on transport/timeout failures when probing `/oauth2/applications/@me`. Landed from contributor PR #29695 by @dhananjai1729. Thanks @dhananjai1729.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #28945 by @Glucksberg. Thanks @Glucksberg.
|
||||
- Discord/EventQueue timeout config: expose per-account `channels.discord.accounts.<id>.eventQueue.listenerTimeout` (and related queue options) so long-running handlers can avoid Carbon listener timeout drops. Landed from contributor PR #24270 by @pdd-cli. Thanks @pdd-cli.
|
||||
- CLI/Cron run exit code: return exit code `0` only when `cron run` reports `{ ok: true, ran: true }`, and `1` for non-run/error outcomes so scripting/debugging reflects actual execution status. Landed from contributor PR #31121 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Cron/Failure delivery routing: add `failureAlert.mode` (`announce|webhook`) and `failureAlert.accountId` support, plus `cron.failureDestination` and per-job `delivery.failureDestination` routing with duplicate-target suppression, best-effort skip behavior, and global+job merge semantics. Landed from contributor PR #31059 by @kesor. Thanks @kesor.
|
||||
- CLI/JSON preflight output: keep `--json` command stdout machine-readable by suppressing doctor preflight note output while still running legacy migration/config doctor flow. (#24368) Thanks @altaywtf.
|
||||
@@ -435,7 +471,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Control UI API routing: when `gateway.controlUi.basePath` is unset (default), stop serving Control UI SPA HTML for `/api` and `/api/*` so API paths fall through to normal gateway handlers/404 responses instead of `index.html`. (#30333) Fixes #30295. thanks @Sid-Qin.
|
||||
- Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks @hugenshen.
|
||||
- Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks @MoerAI.
|
||||
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #28960 by @Glucksberg. Thanks @Glucksberg.
|
||||
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #26157 by @stakeswky. Thanks @stakeswky.
|
||||
- Gateway/Upgrade migration for Control UI origins: seed `gateway.controlUi.allowedOrigins` on startup for legacy non-loopback configs (`lan`/`tailnet`/`custom`) when origins are missing or blank, preventing post-upgrade crash loops while preserving explicit existing policy. Landed from contributor PR #29394 by @synchronic1. Thanks @synchronic1.
|
||||
- Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase.
|
||||
- Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
|
||||
@@ -446,10 +482,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack/Transient request errors: classify Slack request-error messages like `Client network socket disconnected before secure TLS connection was established` as transient in unhandled-rejection fatal detection, preventing temporary network drops from crash-looping the gateway. (#23169) Thanks @graysurf.
|
||||
- Slack/Usage footer formatting: wrap session keys in inline code in full response-usage footers so Slack does not parse colon-delimited session segments as emoji shortcodes. (#30258) Thanks @pushkarsingh32.
|
||||
- Slack/Thread session isolation: route channel/group top-level messages into thread-scoped sessions (`:thread:<ts>`) and read inbound `previousTimestamp` from the resolved thread session key, preventing cross-thread context bleed and stale timestamp lookups. (#10686) Thanks @pablohrcarvalho.
|
||||
- Slack/Socket Mode slash startup: treat `app.options()` registration as best-effort and fall back to static arg menus when listener registration fails, preventing Slack monitor startup crash loops on receiver init edge cases. (#21715) Thanks @Glucksberg.
|
||||
- Slack/Socket Mode slash startup: treat `app.options()` registration as best-effort and fall back to static arg menus when listener registration fails, preventing Slack monitor startup crash loops on receiver init edge cases. (#21715) Thanks @AIflow-Labs.
|
||||
- Slack/Legacy streaming config: map boolean `channels.slack.streaming=false` to unified streaming mode `off` (with `nativeStreaming=false`) so legacy configs correctly disable draft preview/native streaming instead of defaulting to `partial`. (#25990) Thanks @chilu18.
|
||||
- Slack/Socket reconnect reliability: reconnect Socket Mode after disconnect/start failures using bounded exponential backoff with abort-aware waits, while preserving clean shutdown behavior and adding disconnect/error helper tests. (#27232) Thanks @pandego.
|
||||
- Memory/QMD update+embed output cap: discard captured stdout for `qmd update` and `qmd embed` runs (while keeping stderr diagnostics) so large index progress output no longer fails sync with `produced too much output` during boot/refresh. (#28900) Thanks @Glucksberg.
|
||||
- Memory/QMD update+embed output cap: discard captured stdout for `qmd update` and `qmd embed` runs (while keeping stderr diagnostics) so large index progress output no longer fails sync with `produced too much output` during boot/refresh. (#28900; landed from contributor PR #23311 by @haitao-sjsu) Thanks @haitao-sjsu.
|
||||
- Onboarding/Custom providers: raise default custom-provider model context window to the runtime hard minimum (16k) and auto-heal existing custom model entries below that threshold during reconfiguration, preventing immediate `Model context window too small (4096 tokens)` failures. (#21653) Thanks @r4jiv007.
|
||||
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70.
|
||||
- Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz.
|
||||
@@ -691,7 +727,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp/Web reconnect: treat close status `440` as non-retryable (including string-form status values), stop reconnect loops immediately, and emit operator guidance to relink after resolving session conflicts. (#25858) Thanks @markmusson.
|
||||
- WhatsApp/Reasoning safety: suppress outbound payloads marked as reasoning and hard-drop text payloads that begin with `Reasoning:` before WhatsApp delivery, preventing hidden thinking blocks from leaking to end users through final-message paths. (#25804, #25214, #24328)
|
||||
- Matrix/Read receipts: send read receipts as soon as Matrix messages arrive (before handler pipeline work), so clients no longer show long-lived unread/sent states while replies are processing. (#25841, #25840) Thanks @joshjhall.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @Glucksberg.
|
||||
- Telegram/Replies: when markdown formatting renders to empty HTML (for example syntax-only chunks in threaded replies), retry delivery with plain text, and fail loud when both formatted and plain payloads are empty to avoid false delivered states. (#25096, #25091) Thanks @ArsalanShakil.
|
||||
- Telegram/Media fetch: prioritize IPv4 before IPv6 in SSRF pinned DNS address ordering so media downloads still work on hosts with broken IPv6 routing. (#24295, #23975) Thanks @Glucksberg.
|
||||
- Telegram/Outbound API: replace Node 22's global undici dispatcher when applying Telegram `autoSelectFamily` decisions so outbound `fetch` calls inherit IPv4 fallback instead of staying pinned to stale dispatcher settings. (#25682, #25676) Thanks @lairtonlelis.
|
||||
- Onboarding/Telegram: keep core-channel onboarding available when plugin registry population is missing by falling back to built-in adapters and continuing wizard setup with actionable recovery guidance. (#25803) Thanks @Suko.
|
||||
@@ -782,11 +818,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Reasoning: when model-default thinking is active (for example `thinking=low`), keep auto-reasoning disabled unless explicitly enabled, preventing `Reasoning:` thinking-block leakage in channel replies. (#24335, #24290) thanks @Kay-051.
|
||||
- Agents/Reasoning: avoid classifying provider reasoning-required errors as context overflows so these failures no longer trigger compaction-style overflow recovery. (#24593) Thanks @vincentkoc.
|
||||
- Agents/Models: codify `agents.defaults.model` / `agents.defaults.imageModel` config-boundary input as `string | {primary,fallbacks}`, split explicit vs effective model resolution, and fix `models status --agent` source attribution so defaults-inherited agents are labeled as `defaults` while runtime selection still honors defaults fallback. (#24210) thanks @bianbiandashen.
|
||||
- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @Glucksberg.
|
||||
- Agents/Compaction: pass `agentDir` into manual `/compact` command runs so compaction auth/profile resolution stays scoped to the active agent. (#24133) thanks @miloudbelarebia.
|
||||
- Agents/Compaction: pass model metadata through the embedded runtime so safeguard summarization can run when `ctx.model` is unavailable, avoiding repeated `"Summary unavailable due to context limits"` fallback summaries. (#3479) Thanks @battman21, @hanxiao and @vincentkoc.
|
||||
- Agents/Compaction: cancel safeguard compaction when summary generation cannot run (missing model/API key or summarization failure), preserving history instead of truncating to fallback `"Summary unavailable"` text. (#10711) Thanks @DukeDeSouth and @vincentkoc.
|
||||
- Agents/Tools: make `session_status` read transcript-derived usage mid-turn and tail-read session logs for cache-aware context reporting without full-log scans. (#22387) Thanks @1ucian.
|
||||
- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic and @Glucksberg.
|
||||
- Agents/Overflow: detect additional provider context-overflow error shapes (including `input length` + `max_tokens` exceed-context variants) so failures route through compaction/recovery paths instead of leaking raw provider errors to users. (#9951) Thanks @echoVic.
|
||||
- Agents/Overflow: add Chinese context-overflow pattern detection in `isContextOverflowError` so localized provider errors route through overflow recovery paths. (#22855) Thanks @Clawborn.
|
||||
- Agents/Failover: treat HTTP 502/503/504 errors as failover-eligible transient timeouts so fallback chains can switch providers/models during upstream outages instead of retrying the same failing target. (#20999) Thanks @taw0002 and @vincentkoc.
|
||||
- Auto-reply/Inbound metadata: hide direct-chat `message_id`/`message_id_full` and sender metadata only from normalized chat type (not sender-id sentinels), preserving group metadata visibility and preventing sender-id spoofed direct-mode classification. (#24373) thanks @jd316.
|
||||
@@ -932,7 +968,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake.
|
||||
- Feishu/Commands: in group chats, command authorization now falls back to top-level `channels.feishu.allowFrom` when per-group `allowFrom` is not set, so `/command` no longer gets blocked by an unintended empty allowlist. (#23756) Thanks @steipete.
|
||||
- Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc.
|
||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg.
|
||||
- Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349; landed from contributor PR #5005 by @Diaspar4u) Thanks @Diaspar4u.
|
||||
- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) Thanks @steipete.
|
||||
- Hooks/Loader: avoid redundant hook-module recompilation on gateway restart by skipping cache-busting for bundled hooks and using stable file metadata keys (`mtime+size`) for mutable workspace/managed/plugin hook imports. (#16953) Thanks @mudrii.
|
||||
- Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks.
|
||||
@@ -1223,6 +1259,8 @@ Docs: https://docs.openclaw.ai
|
||||
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
|
||||
- iOS/Gateway: wake disconnected iOS nodes via APNs before `nodes.invoke` and auto-reconnect gateway sessions on silent push wake to reduce invoke failures while the app is backgrounded. (#20332) Thanks @mbelinky.
|
||||
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
|
||||
- Mattermost: add opt-in native slash command support with registration lifecycle, callback route/token validation, multi-account token routing, and callback URL/path configuration (`channels.mattermost.commands.*`). (#16515) Thanks @echo931.
|
||||
- Mattermost: harden native slash callback auth-bypass behavior for configurable callback paths, add callback validation coverage, and clarify callback reachability/allowlist docs. (#32467) Thanks @mukhtharcm and @echo931.
|
||||
- iOS/APNs: add push registration and notification-signing configuration for node delivery. (#20308) Thanks @mbelinky.
|
||||
- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky.
|
||||
- Security/Audit: add `gateway.http.no_auth` findings when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable, with loopback warning and remote-exposure critical severity, plus regression coverage and docs updates.
|
||||
@@ -1622,7 +1660,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
|
||||
- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades.
|
||||
- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @briancolinger.
|
||||
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
||||
- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc.
|
||||
- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
|
||||
@@ -1945,7 +1983,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845)
|
||||
- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow.
|
||||
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
|
||||
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @gumadeiras.
|
||||
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
|
||||
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
|
||||
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
|
||||
@@ -2095,7 +2133,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
|
||||
- Cron: reload store data when the store file is recreated or mtime changes.
|
||||
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @sleontenko.
|
||||
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
||||
- Discord: enforce DM allowlists for agent components (buttons/select menus), honoring pairing store approvals and tag matches. (#11254) Thanks @thedudeabidesai.
|
||||
|
||||
@@ -2425,7 +2463,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.openclaw.ai/tools/web
|
||||
- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.openclaw.ai/tools/exec-approvals https://docs.openclaw.ai/tools/slash-commands
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
|
||||
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @steipete.
|
||||
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.openclaw.ai/diagnostics/flags
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
@@ -2438,7 +2476,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Web UI: fix config/debug layout overflow, scrolling, and code block sizing. (#1715) Thanks @saipreetham589.
|
||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
|
||||
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @steipete.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Gateway: allow Control UI token-only auth to skip device pairing even when device identity is present (`gateway.controlUi.allowInsecureAuth`). (#1679) Thanks @steipete.
|
||||
- Matrix: decrypt E2EE media attachments with preflight size guard. (#1744) Thanks @araa47.
|
||||
|
||||
@@ -72,7 +72,7 @@ RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
# Update OPENCLAW_DOCKER_GPG_FINGERPRINT when Docker rotates release keys.
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && \
|
||||
expected_fingerprint="$(printf '%s' "$OPENCLAW_DOCKER_GPG_FINGERPRINT" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')" && \
|
||||
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \"fpr\" { print toupper($10); exit }')" && \
|
||||
actual_fingerprint="$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == "fpr" { print toupper($10); exit }')" && \
|
||||
if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
|
||||
echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})" >&2; \
|
||||
exit 1; \
|
||||
|
||||
389
appcast.xml
389
appcast.xml
@@ -2,6 +2,225 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.3.2</title>
|
||||
<pubDate>Tue, 03 Mar 2026 04:30:29 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, <code>openclaw secrets</code> planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.</li>
|
||||
<li>Tools/PDF analysis: add a first-class <code>pdf</code> tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (<code>agents.defaults.pdfModel</code>, <code>pdfMaxBytesMb</code>, <code>pdfMaxPages</code>), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.</li>
|
||||
<li>Outbound adapters/plugins: add shared <code>sendPayload</code> support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.</li>
|
||||
<li>Models/MiniMax: add first-class <code>MiniMax-M2.5-highspeed</code> support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy <code>MiniMax-M2.5-Lightning</code> compatibility for existing configs.</li>
|
||||
<li>Sessions/Attachments: add inline file attachment support for <code>sessions_spawn</code> (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via <code>tools.sessions_spawn.attachments</code>. (#16761) Thanks @napetrov.</li>
|
||||
<li>Telegram/Streaming defaults: default <code>channels.telegram.streaming</code> to <code>partial</code> (from <code>off</code>) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.</li>
|
||||
<li>Telegram/DM streaming: use <code>sendMessageDraft</code> for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.</li>
|
||||
<li>Telegram/voice mention gating: add optional <code>disableAudioPreflight</code> on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.</li>
|
||||
<li>CLI/Config validation: add <code>openclaw config validate</code> (with <code>--json</code>) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.</li>
|
||||
<li>Tools/Diffs: add PDF file output support and rendering quality customization controls (<code>fileQuality</code>, <code>fileScale</code>, <code>fileMaxWidth</code>) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.</li>
|
||||
<li>Memory/Ollama embeddings: add <code>memorySearch.provider = "ollama"</code> and <code>memorySearch.fallback = "ollama"</code> support, honor <code>models.providers.ollama</code> settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.</li>
|
||||
<li>Zalo Personal plugin (<code>@openclaw/zalouser</code>): rebuilt channel runtime to use native <code>zca-js</code> integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.</li>
|
||||
<li>Plugin SDK/channel extensibility: expose <code>channelRuntime</code> on <code>ChannelGatewayContext</code> so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.</li>
|
||||
<li>Plugin runtime/STT: add <code>api.runtime.stt.transcribeAudioFile(...)</code> so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.</li>
|
||||
<li>Plugin hooks/session lifecycle: include <code>sessionKey</code> in <code>session_start</code>/<code>session_end</code> hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.</li>
|
||||
<li>Hooks/message lifecycle: add internal hook events <code>message:transcribed</code> and <code>message:preprocessed</code>, plus richer outbound <code>message:sent</code> context (<code>isGroup</code>, <code>groupId</code>) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.</li>
|
||||
<li>Media understanding/audio echo: add optional <code>tools.media.audio.echoTranscript</code> + <code>echoFormat</code> to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.</li>
|
||||
<li>Plugin runtime/system: expose <code>runtime.system.requestHeartbeatNow(...)</code> so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.</li>
|
||||
<li>Plugin runtime/events: expose <code>runtime.events.onAgentEvent</code> and <code>runtime.events.onSessionTranscriptUpdate</code> for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.</li>
|
||||
<li>CLI/Banner taglines: add <code>cli.banner.taglineMode</code> (<code>random</code> | <code>default</code> | <code>off</code>) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Onboarding now defaults <code>tools.profile</code> to <code>messaging</code> for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.</li>
|
||||
<li><strong>BREAKING:</strong> ACP dispatch now defaults to enabled unless explicitly disabled (<code>acp.dispatch.enabled=false</code>). If you need to pause ACP turn routing while keeping <code>/acp</code> controls, set <code>acp.dispatch.enabled=false</code>. Docs: https://docs.openclaw.ai/tools/acp-agents</li>
|
||||
<li><strong>BREAKING:</strong> Plugin SDK removed <code>api.registerHttpHandler(...)</code>. Plugins must register explicit HTTP routes via <code>api.registerHttpRoute({ path, auth, match, handler })</code>, and dynamic webhook lifecycles should use <code>registerPluginHttpRoute(...)</code>.</li>
|
||||
<li><strong>BREAKING:</strong> Zalo Personal plugin (<code>@openclaw/zalouser</code>) no longer depends on external <code>zca</code>-compatible CLI binaries (<code>openzca</code>, <code>zca-cli</code>) for runtime send/listen/login; operators should use <code>openclaw channels login --channel zalouser</code> after upgrade to refresh sessions in the new JS-native path.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (<code>trim</code> on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing <code>token.trim()</code> crashes during status/start flows. (#31973) Thanks @ningding97.</li>
|
||||
<li>Discord/lifecycle startup status: push an immediate <code>connected</code> status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.</li>
|
||||
<li>Feishu/LINE group system prompts: forward per-group <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.</li>
|
||||
<li>Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.</li>
|
||||
<li>Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older <code>openclaw/plugin-sdk</code> builds omit webhook default constants. (#31606)</li>
|
||||
<li>Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.</li>
|
||||
<li>Gateway/Subagent TLS pairing: allow authenticated local <code>gateway-client</code> backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring <code>sessions_spawn</code> with <code>gateway.tls.enabled=true</code> in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.</li>
|
||||
<li>Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)</li>
|
||||
<li>Voice-call/runtime lifecycle: prevent <code>EADDRINUSE</code> loops by resetting failed runtime promises, making webhook <code>start()</code> idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when <code>gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback</code> accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example <code>(a|aa)+</code>), and bound large regex-evaluation inputs for session-filter and log-redaction paths.</li>
|
||||
<li>Gateway/Plugin HTTP hardening: require explicit <code>auth</code> for plugin route registration, add route ownership guards for duplicate <code>path+match</code> registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.</li>
|
||||
<li>Browser/Profile defaults: prefer <code>openclaw</code> profile over <code>chrome</code> in headless/no-sandbox environments unless an explicit <code>defaultProfile</code> is configured. (#14944) Thanks @BenediktSchackenberg.</li>
|
||||
<li>Gateway/WS security: keep plaintext <code>ws://</code> loopback-only by default, with explicit break-glass private-network opt-in via <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code>; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.</li>
|
||||
<li>OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit <code>doctor --deep</code>) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.</li>
|
||||
<li>Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.</li>
|
||||
<li>CLI/Config validation and routing hardening: dedupe <code>openclaw config validate</code> failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including <code>--json</code> fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed <code>config get/unset</code> with split root options). Thanks @gumadeiras.</li>
|
||||
<li>Browser/Extension relay reconnect tolerance: keep <code>/json/version</code> and <code>/cdp</code> reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.</li>
|
||||
<li>CLI/Browser start timeout: honor <code>openclaw browser --timeout <ms> start</code> and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.</li>
|
||||
<li>Synology Chat/gateway lifecycle: keep <code>startAccount</code> pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.</li>
|
||||
<li>Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like <code>/usr/bin/g++</code> and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.</li>
|
||||
<li>Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with <code>204</code> to avoid persistent <code>Processing...</code> states in Synology Chat clients. (#26635) Thanks @memphislee09-source.</li>
|
||||
<li>Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.</li>
|
||||
<li>Slack/Bolt startup compatibility: remove invalid <code>message.channels</code> and <code>message.groups</code> event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified <code>message</code> handler (<code>channel_type</code>). (#32033) Thanks @mahopan.</li>
|
||||
<li>Slack/socket auth failure handling: fail fast on non-recoverable auth errors (<code>account_inactive</code>, <code>invalid_auth</code>, etc.) during startup and reconnect instead of retry-looping indefinitely, including <code>unable_to_socket_mode_start</code> error payload propagation. (#32377) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/macOS LaunchAgent hardening: write <code>Umask=077</code> in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
||||
<li>macOS/LaunchAgent security defaults: write <code>Umask=63</code> (octal <code>077</code>) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system <code>022</code>. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from <code>HTTPS_PROXY</code>/<code>HTTP_PROXY</code> env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.</li>
|
||||
<li>Sandbox/workspace mount permissions: make primary <code>/workspace</code> bind mounts read-only whenever <code>workspaceAccess</code> is not <code>rw</code> (including <code>none</code>) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.</li>
|
||||
<li>Tools/fsPolicy propagation: honor <code>tools.fs.workspaceOnly</code> for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.</li>
|
||||
<li>Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like <code>node@22</code>) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.</li>
|
||||
<li>Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.</li>
|
||||
<li>Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded <code>/api/channels/*</code> variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.</li>
|
||||
<li>Browser/Gateway hardening: preserve env credentials for <code>OPENCLAW_GATEWAY_URL</code> / <code>CLAWDBOT_GATEWAY_URL</code> while treating explicit <code>--url</code> as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.</li>
|
||||
<li>Gateway/Control UI basePath webhook passthrough: let non-read methods under configured <code>controlUiBasePath</code> fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.</li>
|
||||
<li>Control UI/Legacy browser compatibility: replace <code>toSorted</code>-dependent cron suggestion sorting in <code>app-render</code> with a compatibility helper so older browsers without <code>Array.prototype.toSorted</code> no longer white-screen. (#31775) Thanks @liuxiaopai-ai.</li>
|
||||
<li>macOS/PeekabooBridge: add compatibility socket symlinks for legacy <code>clawdbot</code>, <code>clawdis</code>, and <code>moltbot</code> Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.</li>
|
||||
<li>Gateway/message tool reliability: avoid false <code>Unknown channel</code> failures when <code>message.*</code> actions receive platform-specific channel ids by falling back to <code>toolContext.currentChannelProvider</code>, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.</li>
|
||||
<li>Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for <code>.cmd</code> shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.</li>
|
||||
<li>Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for <code>sessions_spawn</code> with <code>runtime="acp"</code> by rejecting ACP spawns from sandboxed requester sessions and rejecting <code>sandbox="require"</code> for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.</li>
|
||||
<li>Security/Web tools SSRF guard: keep DNS pinning for untrusted <code>web_fetch</code> and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.</li>
|
||||
<li>Gemini schema sanitization: coerce malformed JSON Schema <code>properties</code> values (<code>null</code>, arrays, primitives) to <code>{}</code> before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.</li>
|
||||
<li>Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.</li>
|
||||
<li>Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.</li>
|
||||
<li>Browser/Extension relay stale tabs: evict stale cached targets from <code>/json/list</code> when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.</li>
|
||||
<li>Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up <code>PortInUseError</code> races after <code>browser start</code>/<code>open</code>. (#29538) Thanks @AaronWander.</li>
|
||||
<li>OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty <code>function_call_output.call_id</code> payloads in the WS conversion path to avoid OpenAI 400 errors (<code>Invalid 'input[n].call_id': empty string</code>), with regression coverage for both inbound stream normalization and outbound payload guards.</li>
|
||||
<li>Security/Nodes camera URL downloads: bind node <code>camera.snap</code>/<code>camera.clip</code> URL payload downloads to the resolved node host, enforce fail-closed behavior when node <code>remoteIp</code> is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.</li>
|
||||
<li>Config/backups hardening: enforce owner-only (<code>0600</code>) permissions on rotated config backups and clean orphan <code>.bak.*</code> files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.</li>
|
||||
<li>Telegram/inbound media filenames: preserve original <code>file_name</code> metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.</li>
|
||||
<li>Gateway/OpenAI chat completions: honor <code>x-openclaw-message-channel</code> when building <code>agentCommand</code> input for <code>/v1/chat/completions</code>, preserving caller channel identity instead of forcing <code>webchat</code>. (#30462) Thanks @bmendonca3.</li>
|
||||
<li>Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.</li>
|
||||
<li>Media/MIME normalization: normalize parameterized/case-variant MIME strings in <code>kindFromMime</code> (for example <code>Audio/Ogg; codecs=opus</code>) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.</li>
|
||||
<li>Discord/audio preflight mentions: detect audio attachments via Discord <code>content_type</code> and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.</li>
|
||||
<li>Feishu/topic session routing: use <code>thread_id</code> as topic session scope fallback when <code>root_id</code> is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.</li>
|
||||
<li>Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of <code>NO_REPLY</code> and keep final-message buffering in sync, preventing partial <code>NO</code> leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.</li>
|
||||
<li>Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.</li>
|
||||
<li>Voice-call/Twilio external outbound: auto-register webhook-first <code>outbound-api</code> calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/topic root replies: prefer <code>root_id</code> as outbound <code>replyTargetMessageId</code> when present, and parse millisecond <code>message_create_time</code> values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/DM pairing reply target: send pairing challenge replies to <code>chat:<chat_id></code> instead of <code>user:<sender_open_id></code> so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.</li>
|
||||
<li>Feishu/Lark private DM routing: treat inbound <code>chat_type: "private"</code> as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.</li>
|
||||
<li>Signal/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.</li>
|
||||
<li>Discord/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.</li>
|
||||
<li>Synology Chat/reply delivery: resolve webhook usernames to Chat API <code>user_id</code> values for outbound chatbot replies, avoiding mismatches between webhook user IDs and <code>method=chatbot</code> recipient IDs in multi-account setups. (#23709) Thanks @druide67.</li>
|
||||
<li>Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.</li>
|
||||
<li>Slack/session routing: keep top-level channel messages in one shared session when <code>replyToMode=off</code>, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.</li>
|
||||
<li>Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.</li>
|
||||
<li>Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.</li>
|
||||
<li>Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (<code>monitor.account-scope.test.ts</code>) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Send target prefixes: normalize explicit <code>group:</code>/<code>dm:</code> send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Webchat/Feishu session continuation: preserve routable <code>OriginatingChannel</code>/<code>OriginatingTo</code> metadata from session delivery context in <code>chat.send</code>, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)</li>
|
||||
<li>Telegram/implicit mention forum handling: exclude Telegram forum system service messages (<code>forum_topic_*</code>, <code>general_forum_topic_*</code>) from reply-chain implicit mention detection so <code>requireMention</code> does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.</li>
|
||||
<li>Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (<code>provider: "message"</code>) and normalize <code>lark</code>/<code>feishu</code> provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)</li>
|
||||
<li>Webchat/silent token leak: filter assistant <code>NO_REPLY</code>-only transcript entries from <code>chat.history</code> responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.</li>
|
||||
<li>Doctor/local memory provider checks: stop false-positive local-provider warnings when <code>provider=local</code> and no explicit <code>modelPath</code> is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.</li>
|
||||
<li>Media understanding/parakeet CLI output parsing: read <code>parakeet-mlx</code> transcripts from <code>--output-dir/<media-basename>.txt</code> when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.</li>
|
||||
<li>Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.</li>
|
||||
<li>Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Node browser proxy routing: honor <code>profile</code> from <code>browser.request</code> JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Control UI basePath POST handling: return 405 for <code>POST</code> on exact basePath routes (for example <code>/openclaw</code>) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.</li>
|
||||
<li>Browser/default profile selection: default <code>browser.defaultProfile</code> behavior now prefers <code>openclaw</code> (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the <code>chrome</code> relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.</li>
|
||||
<li>Models/config env propagation: apply <code>config.env.vars</code> before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.</li>
|
||||
<li>Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so <code>openclaw models status</code> no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.</li>
|
||||
<li>Gateway/Heartbeat model reload: treat <code>models.*</code> and <code>agents.defaults.model</code> config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.</li>
|
||||
<li>Memory/LanceDB embeddings: forward configured <code>embedding.dimensions</code> into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.</li>
|
||||
<li>Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.</li>
|
||||
<li>Browser/CDP status accuracy: require a successful <code>Browser.getVersion</code> response over the CDP websocket (not just socket-open) before reporting <code>cdpReady</code>, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.</li>
|
||||
<li>Daemon/systemd checks in containers: treat missing <code>systemctl</code> invocations (including <code>spawn systemctl ENOENT</code>/<code>EACCES</code>) as unavailable service state during <code>is-enabled</code> checks, preventing container flows from failing with <code>Gateway service check failed</code> before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.</li>
|
||||
<li>Security/Node exec approvals: revalidate approval-bound <code>cwd</code> identity immediately before execution/forwarding and fail closed with an explicit denial when <code>cwd</code> drifts after approval hardening.</li>
|
||||
<li>Security audit/skills workspace hardening: add <code>skills.workspace.symlink_escape</code> warning in <code>openclaw security audit</code> when workspace <code>skills/**/SKILL.md</code> resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.</li>
|
||||
<li>Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example <code>env sh -c ...</code>) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/fs-safe write hardening: make <code>writeFileWithinRoot</code> use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.</li>
|
||||
<li>Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.</li>
|
||||
<li>Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like <code>[System Message]</code> and line-leading <code>System:</code> in untrusted message content. (#30448)</li>
|
||||
<li>Sandbox/Docker setup command parsing: accept <code>agents.*.sandbox.docker.setupCommand</code> as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction <code>AGENTS.md</code> context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.</li>
|
||||
<li>Agents/Sandbox workdir mapping: map container workdir paths (for example <code>/workspace</code>) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Docker/Sandbox bootstrap hardening: make <code>OPENCLAW_SANDBOX</code> opt-in parsing explicit (<code>1|true|yes|on</code>), support custom Docker socket paths via <code>OPENCLAW_DOCKER_SOCKET</code>, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to <code>off</code> when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.</li>
|
||||
<li>Hooks/webhook ACK compatibility: return <code>200</code> (instead of <code>202</code>) for successful <code>/hooks/agent</code> requests so providers that require <code>200</code> (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.</li>
|
||||
<li>Feishu/Run channel fallback: prefer <code>Provider</code> over <code>Surface</code> when inferring queued run <code>messageProvider</code> fallback (when <code>OriginatingChannel</code> is missing), preventing Feishu turns from being mislabeled as <code>webchat</code> in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Skills/sherpa-onnx-tts: run the <code>sherpa-onnx-tts</code> bin under ESM (replace CommonJS <code>require</code> imports) and add regression coverage to prevent <code>require is not defined in ES module scope</code> startup crashes. (#31965) Thanks @bmendonca3.</li>
|
||||
<li>Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.</li>
|
||||
<li>Slack/Channel message subscriptions: register explicit <code>message.channels</code> and <code>message.groups</code> monitor handlers (alongside generic <code>message</code>) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.</li>
|
||||
<li>Hooks/session-scoped memory context: expose ephemeral <code>sessionId</code> in embedded plugin tool contexts and <code>before_tool_call</code>/<code>after_tool_call</code> hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across <code>/new</code> and <code>/reset</code>. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.</li>
|
||||
<li>Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.</li>
|
||||
<li>Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.</li>
|
||||
<li>Feishu/File upload filenames: percent-encode non-ASCII/special-character <code>file_name</code> values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.</li>
|
||||
<li>Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized <code>kindFromMime</code> so mixed-case/parameterized MIME values classify consistently across message channels.</li>
|
||||
<li>WhatsApp/inbound self-message context: propagate inbound <code>fromMe</code> through the web inbox pipeline and annotate direct self messages as <code>(self)</code> in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.</li>
|
||||
<li>Webchat/stream finalization: persist streamed assistant text when final events omit <code>message</code>, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)</li>
|
||||
<li>Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)</li>
|
||||
<li>Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)</li>
|
||||
<li>Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)</li>
|
||||
<li>Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured <code>LarkApiError</code> responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)</li>
|
||||
<li>Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (<code>contact:contact.base:readonly</code>) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)</li>
|
||||
<li>BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound <code>message_id</code> selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.</li>
|
||||
<li>WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.</li>
|
||||
<li>Feishu/default account resolution: always honor explicit <code>channels.feishu.defaultAccount</code> during outbound account selection (including top-level-credential setups where the preferred id is not present in <code>accounts</code>), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (<code>contact:contact.base:readonly</code>) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)</li>
|
||||
<li>Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)</li>
|
||||
<li>Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.</li>
|
||||
<li>Browser/Extension re-announce reliability: keep relay state in <code>connecting</code> when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.</li>
|
||||
<li>Browser/Act request compatibility: accept legacy flattened <code>action="act"</code> params (<code>kind/ref/text/...</code>) in addition to <code>request={...}</code> so browser act calls no longer fail with <code>request required</code>. (#15120) Thanks @vincentkoc.</li>
|
||||
<li>OpenRouter/x-ai compatibility: skip <code>reasoning.effort</code> injection for <code>x-ai/*</code> models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.</li>
|
||||
<li>Models/openai-completions developer-role compatibility: force <code>supportsDeveloperRole=false</code> for non-native endpoints, treat unparseable <code>baseUrl</code> values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.</li>
|
||||
<li>Browser/Profile attach-only override: support <code>browser.profiles.<name>.attachOnly</code> (fallback to global <code>browser.attachOnly</code>) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.</li>
|
||||
<li>Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file <code>starttime</code> with <code>/proc/<pid>/stat</code> starttime, so stale <code>.jsonl.lock</code> files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.</li>
|
||||
<li>Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel <code>resolveDefaultTo</code> fallback) when <code>delivery.to</code> is omitted. (#32364) Thanks @hclsys.</li>
|
||||
<li>OpenAI media capabilities: include <code>audio</code> in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.</li>
|
||||
<li>Browser/Managed tab cap: limit loopback managed <code>openclaw</code> page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.</li>
|
||||
<li>Docker/Image health checks: add Dockerfile <code>HEALTHCHECK</code> that probes gateway <code>GET /healthz</code> so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.</li>
|
||||
<li>Gateway/Node dangerous-command parity: include <code>sms.send</code> in default onboarding node <code>denyCommands</code>, share onboarding deny defaults with the gateway dangerous-command source of truth, and include <code>sms.send</code> in phone-control <code>/phone arm writes</code> handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.</li>
|
||||
<li>Pairing/AllowFrom account fallback: handle omitted <code>accountId</code> values in <code>readChannelAllowFromStore</code> and <code>readChannelAllowFromStoreSync</code> as <code>default</code>, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.</li>
|
||||
<li>Browser/CDP proxy bypass: force direct loopback agent paths and scoped <code>NO_PROXY</code> expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Sessions/idle reset correctness: preserve existing <code>updatedAt</code> during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.</li>
|
||||
<li>Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing <code>starttime</code> when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.</li>
|
||||
<li>Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (<code>mtimeMs</code> + <code>sizeBytes</code>), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.</li>
|
||||
<li>Agents/Subagents <code>sessions_spawn</code>: reject malformed <code>agentId</code> inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.</li>
|
||||
<li>CLI/installer Node preflight: enforce Node.js <code>v22.12+</code> consistently in both <code>openclaw.mjs</code> runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.</li>
|
||||
<li>Web UI/config form: support SecretInput string-or-secret-ref unions in map <code>additionalProperties</code>, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.</li>
|
||||
<li>Auto-reply/inline command cleanup: preserve newline structure when stripping inline <code>/status</code> and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.</li>
|
||||
<li>Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like <code>source</code>/<code>provider</code>), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.</li>
|
||||
<li>Hooks/runtime stability: keep the internal hook handler registry on a <code>globalThis</code> singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.</li>
|
||||
<li>Hooks/after_tool_call: include embedded session context (<code>sessionKey</code>, <code>agentId</code>) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.</li>
|
||||
<li>Hooks/tool-call correlation: include <code>runId</code> and <code>toolCallId</code> in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in <code>before_tool_call</code> and <code>after_tool_call</code>. (#32360) Thanks @vincentkoc.</li>
|
||||
<li>Plugins/install diagnostics: reject legacy plugin package shapes without <code>openclaw.extensions</code> and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Hooks/plugin context parity: ensure <code>llm_input</code> hooks in embedded attempts receive the same <code>trigger</code> and <code>channelId</code>-aware <code>hookCtx</code> used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.</li>
|
||||
<li>Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (<code>pnpm</code>, <code>bun</code>) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.</li>
|
||||
<li>Cron/session reaper reliability: move cron session reaper sweeps into <code>onTimer</code> <code>finally</code> and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.</li>
|
||||
<li>Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so <code>HEARTBEAT_OK</code> noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.</li>
|
||||
<li>Authentication: classify <code>permission_error</code> as <code>auth_permanent</code> for profile fallback. (#31324) Thanks @Sid-Qin.</li>
|
||||
<li>Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (<code>newText</code> present and <code>oldText</code> absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.</li>
|
||||
<li>Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example <code>diffs</code> -> bundled <code>@openclaw/diffs</code>), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.</li>
|
||||
<li>Web UI/inline code copy fidelity: disable forced mid-token wraps on inline <code><code></code> spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.</li>
|
||||
<li>Restart sentinel formatting: avoid duplicate <code>Reason:</code> lines when restart message text already matches <code>stats.reason</code>, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.</li>
|
||||
<li>Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.</li>
|
||||
<li>Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.</li>
|
||||
<li>Failover/error classification: treat HTTP <code>529</code> (provider overloaded, common with Anthropic-compatible APIs) as <code>rate_limit</code> so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.</li>
|
||||
<li>Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.</li>
|
||||
<li>Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.</li>
|
||||
<li>Secrets/exec resolver timeout defaults: use provider <code>timeoutMs</code> as the default inactivity (<code>noOutputTimeoutMs</code>) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.</li>
|
||||
<li>Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.</li>
|
||||
<li>Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing <code>HEARTBEAT_OK</code> from being delivered to users. (#32131) Thanks @adhishthite.</li>
|
||||
<li>Cron/store migration: normalize legacy cron jobs with string <code>schedule</code> and top-level <code>command</code>/<code>timeout</code> fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.</li>
|
||||
<li>Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.</li>
|
||||
<li>Tests/Subagent announce: set <code>OPENCLAW_TEST_FAST=1</code> before importing <code>subagent-announce</code> format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.</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.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.1</title>
|
||||
<pubDate>Mon, 02 Mar 2026 04:40:59 +0000</pubDate>
|
||||
@@ -140,175 +359,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.1/OpenClaw-2026.3.1.zip" length="12804155" type="application/octet-stream" sparkle:edSignature="TF1otD4Vk3pG0iViX7mvix5DQEgAsk4JkSFvH7opjf9aawV16f29SUa2wRmiCFU6HEgyNgnGI/078O+A27eXCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.15</title>
|
||||
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602150</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.</li>
|
||||
<li>Plugins: expose <code>llm_input</code> and <code>llm_output</code> hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.</li>
|
||||
<li>Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set <code>agents.defaults.subagents.maxSpawnDepth: 2</code> to allow sub-agents to spawn their own children. Includes <code>maxChildrenPerAgent</code> limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.</li>
|
||||
<li>Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.</li>
|
||||
<li>Cron/Gateway: add finished-run webhook delivery toggle (<code>notify</code>) and dedicated webhook auth token support (<code>cron.webhookToken</code>) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.</li>
|
||||
<li>Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.</li>
|
||||
<li>Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.</li>
|
||||
<li>Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.</li>
|
||||
<li>Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.</li>
|
||||
<li>Gateway/Security: redact sensitive session/path details from <code>status</code> responses for non-admin clients; full details remain available to <code>operator.admin</code>. (#8590) Thanks @fr33d3m0n.</li>
|
||||
<li>Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (<code>allowInsecureAuth</code> / <code>dangerouslyDisableDeviceAuth</code>) when device identity is unavailable, preventing false <code>missing scope</code> failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.</li>
|
||||
<li>LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.</li>
|
||||
<li>Skills/Security: restrict <code>download</code> installer <code>targetDir</code> to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.</li>
|
||||
<li>Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.</li>
|
||||
<li>Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.</li>
|
||||
<li>Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving <code>passwordFile</code> path exemptions, preventing accidental redaction of non-secret config values like <code>maxTokens</code> and IRC password-file paths. (#16042) Thanks @akramcodez.</li>
|
||||
<li>Dev tooling: harden git <code>pre-commit</code> hook against option injection from malicious filenames (for example <code>--force</code>), preventing accidental staging of ignored files. Thanks @mrthankyou.</li>
|
||||
<li>Gateway/Agent: reject malformed <code>agent:</code>-prefixed session keys (for example, <code>agent:main</code>) in <code>agent</code> and <code>agent.identity.get</code> instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.</li>
|
||||
<li>Gateway/Chat: harden <code>chat.send</code> inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.</li>
|
||||
<li>Gateway/Send: return an actionable error when <code>send</code> targets internal-only <code>webchat</code>, guiding callers to use <code>chat.send</code> or a deliverable channel. (#15703) Thanks @rodrigouroz.</li>
|
||||
<li>Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing <code>script-src 'self'</code>. Thanks @Adam55A-code.</li>
|
||||
<li>Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.</li>
|
||||
<li>Agents/Sandbox: clarify system prompt path guidance so sandbox <code>bash/exec</code> uses container paths (for example <code>/workspace</code>) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.</li>
|
||||
<li>Agents/Context: apply configured model <code>contextWindow</code> overrides after provider discovery so <code>lookupContextTokens()</code> honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.</li>
|
||||
<li>Agents/Context: derive <code>lookupContextTokens()</code> from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.</li>
|
||||
<li>Agents/OpenAI: force <code>store=true</code> for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.</li>
|
||||
<li>Memory/FTS: make <code>buildFtsQuery</code> Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.</li>
|
||||
<li>Auto-reply/Compaction: resolve <code>memory/YYYY-MM-DD.md</code> placeholders with timezone-aware runtime dates and append a <code>Current time:</code> line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.</li>
|
||||
<li>Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.</li>
|
||||
<li>Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.</li>
|
||||
<li>Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.</li>
|
||||
<li>Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Subagents/Models: preserve <code>agents.defaults.model.fallbacks</code> when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.</li>
|
||||
<li>Telegram: omit <code>message_thread_id</code> for DM sends/draft previews and keep forum-topic handling (<code>id=1</code> general omitted, non-general kept), preventing DM failures with <code>400 Bad Request: message thread not found</code>. (#10942) Thanks @garnetlyx.</li>
|
||||
<li>Telegram: replace inbound <code><media:audio></code> placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.</li>
|
||||
<li>Telegram: retry inbound media <code>getFile</code> calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.</li>
|
||||
<li>Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.</li>
|
||||
<li>Discord: preserve channel session continuity when runtime payloads omit <code>message.channelId</code> by falling back to event/raw <code>channel_id</code> values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as <code>sessionKey=unknown</code>. (#17622) Thanks @shakkernerd.</li>
|
||||
<li>Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with <code>_2</code> suffixes. (#17365) Thanks @seewhyme.</li>
|
||||
<li>Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.</li>
|
||||
<li>Web UI/Agents: hide <code>BOOTSTRAP.md</code> in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.</li>
|
||||
<li>Auto-reply/WhatsApp/TUI/Web: when a final assistant message is <code>NO_REPLY</code> and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show <code>NO_REPLY</code> placeholders. (#7010) Thanks @Morrowind-Xie.</li>
|
||||
<li>Cron: infer <code>payload.kind="agentTurn"</code> for model-only <code>cron.update</code> payload patches, so partial agent-turn updates do not fail validation when <code>kind</code> is omitted. (#15664) Thanks @rodrigouroz.</li>
|
||||
<li>TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.</li>
|
||||
<li>TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.</li>
|
||||
<li>TUI: suppress false <code>(no output)</code> placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.</li>
|
||||
<li>TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.</li>
|
||||
<li>CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.</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.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.26</title>
|
||||
<pubDate>Thu, 26 Feb 2026 23:37:15 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602260</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.26</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.26</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Highlight: External Secrets Management introduces a full <code>openclaw secrets</code> workflow (<code>audit</code>, <code>configure</code>, <code>apply</code>, <code>reload</code>) with runtime snapshot activation, strict <code>secrets apply</code> target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.</li>
|
||||
<li>ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with <code>acp</code> spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.</li>
|
||||
<li>Agents/Routing CLI: add <code>openclaw agents bindings</code>, <code>openclaw agents bind</code>, and <code>openclaw agents unbind</code> for account-scoped route management, including channel-only to account-scoped binding upgrades, role-aware binding identity handling, plugin-resolved binding account IDs, and optional account-binding prompts in <code>openclaw channels add</code>. (#27195) thanks @gumadeiras.</li>
|
||||
<li>Codex/WebSocket transport: make <code>openai-codex</code> WebSocket-first by default (<code>transport: "auto"</code> with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.</li>
|
||||
<li>Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional <code>configureInteractive</code> and <code>configureWhenConfigured</code> hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.</li>
|
||||
<li>Android/Nodes: add Android <code>device</code> capability plus <code>device.status</code> and <code>device.info</code> node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.</li>
|
||||
<li>Android/Nodes: add <code>notifications.list</code> support on Android nodes and expose <code>nodes notifications_list</code> in agent tooling for listing active device notifications. (#27344) thanks @obviyus.</li>
|
||||
<li>Docs/Contributing: add Nimrod Gutman to the maintainer roster in <code>CONTRIBUTING.md</code>. (#27840) Thanks @ngutman.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram/DM allowlist runtime inheritance: enforce <code>dmPolicy: "allowlist"</code> <code>allowFrom</code> requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align <code>openclaw doctor</code> checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Delivery queue/recovery backoff: prevent retry starvation by persisting <code>lastAttemptAt</code> on failed sends and deferring recovery retries until each entry's <code>lastAttemptAt + backoff</code> window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.</li>
|
||||
<li>Google Chat/Lifecycle: keep Google Chat <code>startAccount</code> pending until abort in webhook mode so startup is no longer interpreted as immediate exit, preventing auto-restart loops and webhook-target churn. (#27384) thanks @junsuwhy.</li>
|
||||
<li>Temp dirs/Linux umask: force <code>0700</code> permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so <code>umask 0002</code> installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.</li>
|
||||
<li>Nextcloud Talk/Lifecycle: keep <code>startAccount</code> pending until abort and stop the webhook monitor on shutdown, preventing <code>EADDRINUSE</code> restart loops when the gateway manages account lifecycle. (#27897)</li>
|
||||
<li>Microsoft Teams/File uploads: acknowledge <code>fileConsent/invoke</code> immediately (<code>invokeResponse</code> before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011.</li>
|
||||
<li>Queue/Drain/Cron reliability: harden lane draining with guaranteed <code>draining</code> flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add <code>/stop</code> queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron <code>agentTurn</code> outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)</li>
|
||||
<li>Typing/Main reply pipeline: always mark dispatch idle in <code>agent-runner</code> finalization so typing cleanup runs even when dispatcher <code>onIdle</code> does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.</li>
|
||||
<li>Typing/TTL safety net: add max-duration guardrails to shared typing callbacks so stuck lifecycle edges auto-stop typing indicators even when explicit idle/cleanup signals are missed. (#27428) Thanks @Crpdim.</li>
|
||||
<li>Typing/Cross-channel leakage: unify run-scoped typing suppression for cross-channel/internal-webchat routes, preserve current inbound origin as embedded run message channel context, harden shared typing keepalive with consecutive-failure circuit breaker edge-case handling, and enforce dispatcher completion/idle waits in extension dispatcher callsites (Feishu, Matrix, Mattermost, MSTeams) so typing indicators always clean up on success/error paths. Related: #27647, #27493, #27598. Supersedes/replaces draft PRs: #27640, #27593, #27540.</li>
|
||||
<li>Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded <code>sendChatAction</code> retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Telegram/Webhook startup: clarify webhook config guidance, allow <code>channels.telegram.webhookPort: 0</code> for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.</li>
|
||||
<li>Browser/Chrome extension handshake: bind relay WS message handling before <code>onopen</code> and add non-blocking <code>connect.challenge</code> response handling for gateway-style handshake frames, avoiding stuck <code>…</code> badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)</li>
|
||||
<li>Browser/Extension relay init: dedupe concurrent same-port relay startup with shared in-flight initialization promises so callers await one startup lifecycle and receive consistent success/failure results. Landed from contributor PR #21277 by @HOYALIM. (Related #20688)</li>
|
||||
<li>Browser/Fill relay + CLI parity: accept <code>act.fill</code> fields without explicit <code>type</code> by defaulting missing/empty <code>type</code> to <code>text</code> in both browser relay route parsing and <code>openclaw browser fill</code> CLI field parsing, so relay calls no longer fail when the model omits field type metadata. Landed from contributor PR #27662 by @Uface11. (#27296) Thanks @Uface11.</li>
|
||||
<li>Feishu/Permission error dispatch: merge sender-name permission notices into the main inbound dispatch so one user message produces one agent turn/reply (instead of a duplicate permission-notice turn), with regression coverage. (#27381) thanks @byungsker.</li>
|
||||
<li>Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single <code>mac-*</code> candidate is selected, default to the first connected candidate instead of failing with <code>node required</code> for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.</li>
|
||||
<li>TUI/stream assembly: preserve streamed text across real tool-boundary drops without keeping stale streamed text when non-text blocks appear only in the final payload. Landed from contributor PR #27711 by @scz2011. (#27674)</li>
|
||||
<li>Hooks/Internal <code>message:sent</code>: forward <code>sessionKey</code> on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal <code>message:sent</code> hooks consistently dispatch with session context, including <code>openclaw agent --deliver</code> runs resumed via <code>--session-id</code> (without explicit <code>--session-key</code>). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.</li>
|
||||
<li>Pi image-token usage: stop re-injecting history image blocks each turn, process image references from the current prompt only, and prune already-answered user-image blocks in stored history to prevent runaway token growth. (#27602)</li>
|
||||
<li>BlueBubbles/SSRF: auto-allowlist the configured <code>serverUrl</code> hostname for attachment fetches so localhost/private-IP BlueBubbles setups are no longer false-blocked by default SSRF checks. Landed from contributor PR #27648 by @lailoo. (#27599) Thanks @taylorhou for reporting.</li>
|
||||
<li>Agents/Compaction + onboarding safety: prevent destructive double-compaction by stripping stale assistant usage around compaction boundaries, skipping post-compaction custom metadata writes in the same attempt, and cancelling safeguard compaction when there are no real conversation messages to summarize; harden workspace/bootstrap detection for memory-backed workspaces; and change <code>openclaw onboard --reset</code> default scope to <code>config+creds+sessions</code> (workspace deletion now requires <code>--reset-scope full</code>). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.</li>
|
||||
<li>NO_REPLY suppression: suppress <code>NO_REPLY</code> before Slack API send and in sub-agent announce completion flow so sentinel text no longer leaks into user channels. Landed from contributor PRs #27529 (by @Sid-Qin) and #27535 (rewritten minimal landing by maintainers). (#27387, #27531)</li>
|
||||
<li>Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (<code>BodyForAgent</code>) for both channel and threaded messages, and align group envelopes with shared inbound sender-prefix formatting so first-person requests resolve against the current sender. (#27401) thanks @koushikxd.</li>
|
||||
<li>Auto-reply/Streaming: suppress only exact <code>NO_REPLY</code> final replies while still filtering streaming partial sentinel fragments (<code>NO_</code>, <code>NO_RE</code>, <code>HEARTBEAT_...</code>) so substantive replies ending with <code>NO_REPLY</code> are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.</li>
|
||||
<li>Auto-reply/Inbound metadata: add a readable <code>timestamp</code> field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.</li>
|
||||
<li>Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding <code>triggerTyping()</code> with <code>runComplete</code>, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Typing/Dispatch idle: force typing cleanup when <code>markDispatchIdle</code> never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)</li>
|
||||
<li>Telegram/Inline buttons: allow callback-query button handling in groups (including <code>/models</code> follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.</li>
|
||||
<li>Telegram/Streaming preview: when finalizing without an existing preview message, prime pending preview text with final answer before stop-flush so users do not briefly see stale 1-2 word fragments (for example <code>no</code> before <code>no problem</code>). (#27449) Thanks @emanuelst for the original fix direction in #19673.</li>
|
||||
<li>Browser/Extension relay CORS: handle <code>/json*</code> <code>OPTIONS</code> preflight before auth checks, allow Chrome extension origins, and return extension-origin CORS headers on relay HTTP responses so extension token validation no longer fails cross-origin. Landed from contributor PR #23962 by @miloudbelarebia. (#23842)</li>
|
||||
<li>Browser/Extension relay auth: allow <code>?token=</code> query-param auth on relay <code>/json*</code> endpoints (consistent with relay WebSocket auth) so curl/devtools-style <code>/json/version</code> and <code>/json/list</code> probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)</li>
|
||||
<li>Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay <code>stop()</code> before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.</li>
|
||||
<li>Browser/Extension relay reconnect resilience: keep CDP clients alive across brief MV3 extension disconnect windows, wait briefly for extension reconnect before failing in-flight CDP commands, and only tear down relay target/client state after reconnect grace expires. Landed from contributor PR #27617 by @davidemanuelDEV.</li>
|
||||
<li>Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted <code>%</code> paths return <code>400</code> instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.</li>
|
||||
<li>Feishu/Inbound message metadata: include inbound <code>message_id</code> in <code>BodyForAgent</code> on a dedicated metadata line so agents can reliably correlate and act on media/message operations that require message IDs, with regression coverage. (#27253) thanks @xss925175263.</li>
|
||||
<li>Feishu/Doc tools: route <code>feishu_doc</code> and <code>feishu_app_scopes</code> through the active agent account context (with explicit <code>accountId</code> override support) so multi-account agents no longer default to the first configured app, with regression coverage for context routing and explicit override behavior. (#27338) thanks @AaronL725.</li>
|
||||
<li>LINE/Inline directives auth: gate directive parsing (<code>/model</code>, <code>/think</code>, <code>/verbose</code>, <code>/reasoning</code>, <code>/queue</code>) on resolved authorization (<code>command.isAuthorizedSender</code>) so <code>commands.allowFrom</code>-authorized LINE senders are not silently stripped when raw <code>CommandAuthorized</code> is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)</li>
|
||||
<li>Onboarding/Gateway: seed default Control UI <code>allowedOrigins</code> for non-loopback binds during onboarding (<code>localhost</code>/<code>127.0.0.1</code> plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.</li>
|
||||
<li>Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during <code>pnpm install</code>, reuse existing gateway token during <code>docker-setup.sh</code> reruns so <code>.env</code> stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego.</li>
|
||||
<li>CLI/Gateway <code>--force</code> in non-root Docker: recover from <code>lsof</code> permission failures (<code>EACCES</code>/<code>EPERM</code>) by falling back to <code>fuser</code> kill + probe-based port checks, so <code>openclaw gateway --force</code> works for default container <code>node</code> user flows. (#27941)</li>
|
||||
<li>Gateway/Bind visibility: emit a startup warning when binding to non-loopback addresses so operators get explicit exposure guidance in runtime logs. (#25397) thanks @let5sne.</li>
|
||||
<li>Sessions cleanup/Doctor: add <code>openclaw sessions cleanup --fix-missing</code> to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)</li>
|
||||
<li>Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so <code>openclaw doctor</code> no longer reports false-positive transcript-missing warnings for <code>*:slash:*</code> keys. (#27375) thanks @gumadeiras.</li>
|
||||
<li>CLI/Gateway status: force local <code>gateway status</code> probe host to <code>127.0.0.1</code> for <code>bind=lan</code> so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.</li>
|
||||
<li>CLI/Gateway auth: align <code>gateway run --auth</code> parsing/help text with supported gateway auth modes by accepting <code>none</code> and <code>trusted-proxy</code> (in addition to <code>token</code>/<code>password</code>) for CLI overrides. (#27469) thanks @s1korrrr.</li>
|
||||
<li>CLI/Daemon status TLS probe: use <code>wss://</code> and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so <code>openclaw daemon status</code> works with <code>gateway.bind=lan</code> + <code>gateway.tls.enabled=true</code>. (#24234) thanks @liuy.</li>
|
||||
<li>Podman/Default bind: change <code>run-openclaw-podman.sh</code> default gateway bind from <code>lan</code> to <code>loopback</code> and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.</li>
|
||||
<li>Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent <code>KeepAlive=true</code> semantics, and harden restart sequencing to <code>print -> bootout -> wait old pid exit -> bootstrap -> kickstart</code>. (#27276) thanks @frankekn.</li>
|
||||
<li>Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before <code>/restart</code> launchctl/systemctl triggers, and set LaunchAgent <code>ThrottleInterval=60</code> to bound launchd retry storms during lock-release races. Landed from contributor PRs #27655 (@taw0002), #27448 (@Sid-Qin), and #27650 (@kevinWangSheng). (#27605, #27590, #26904, #26736)</li>
|
||||
<li>Models/MiniMax auth header defaults: set <code>authHeader: true</code> for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (<code>minimax</code>, <code>minimax-portal</code>) provider templates so first requests no longer fail with MiniMax <code>401 authentication_error</code> due to missing <code>Authorization</code> header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)</li>
|
||||
<li>Auth/Auth profiles: normalize <code>auth-profiles.json</code> alias fields (<code>mode -> type</code>, <code>apiKey -> key</code>) before credential validation so entries copied from <code>openclaw.json</code> auth examples are no longer silently dropped. (#26950) thanks @byungsker.</li>
|
||||
<li>Models/Profile suffix parsing: centralize trailing <code>@profile</code> parsing and only treat <code>@</code> as a profile separator when it appears after the final <code>/</code>, preserving model IDs like <code>openai/@cf/...</code> and <code>openrouter/@preset/...</code> across <code>/model</code> directive parsing and allowlist model resolution, with regression coverage.</li>
|
||||
<li>Models/OpenAI Codex config schema parity: accept <code>openai-codex-responses</code> in the config model API schema and TypeScript <code>ModelApi</code> union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.</li>
|
||||
<li>Agents/Models config: preserve agent-level provider <code>apiKey</code> and <code>baseUrl</code> during merge-mode <code>models.json</code> updates when agent values are present. (#27293) thanks @Sid-Qin.</li>
|
||||
<li>Azure OpenAI Responses: force <code>store=true</code> for <code>azure-openai-responses</code> direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)</li>
|
||||
<li>Security/Node exec approvals: require structured <code>commandArgv</code> approvals for <code>host=node</code>, enforce versioned <code>systemRunBindingV1</code> matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add <code>GIT_EXTERNAL_DIFF</code> to blocked host env keys. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Plugin channel HTTP auth: normalize protected <code>/api/channels</code> path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed <code>%</code>-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Gateway node pairing: pin paired-device <code>platform</code>/<code>deviceFamily</code> metadata across reconnects and bind those fields into device-auth signatures, so reconnect metadata spoofing cannot expand node command allowlists without explicit repair pairing. This ships in the next npm release (<code>2026.2.26</code>). Thanks @76embiid21 for reporting.</li>
|
||||
<li>Security/Sandbox path alias guard: reject broken symlink targets by resolving through existing ancestors and failing closed on out-of-root targets, preventing workspace-only <code>apply_patch</code> writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Config includes: harden <code>$include</code> file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (<code>2026.2.26</code>). Thanks @zpbrent for reporting.</li>
|
||||
<li>Security/Node exec approvals hardening: freeze immutable approval-time execution plans (<code>argv</code>/<code>cwd</code>/<code>agentId</code>/<code>sessionKey</code>) via <code>system.run.prepare</code>, enforce those canonical plan values during approval forwarding/execution, and reject mutable parent-symlink cwd paths during approval-plan building to prevent approval bypass via symlink rebind. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned <code>i-twilio-idempotency-token</code> trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.</li>
|
||||
<li>Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (<code>2026.2.26</code>). Thanks @tdjackey for reporting and @gumadeiras for implementation.</li>
|
||||
<li>Config/Plugins entries: treat unknown <code>plugins.entries.*</code> ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)</li>
|
||||
<li>Telegram native commands: degrade command registration on <code>BOT_COMMANDS_TOO_MUCH</code> by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)</li>
|
||||
<li>Web tools/Proxy: route <code>web_search</code> provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and <code>web_fetch</code> through a shared proxy-aware SSRF guard path so gateway installs behind <code>HTTP_PROXY</code>/<code>HTTPS_PROXY</code>/<code>ALL_PROXY</code> no longer fail with transport <code>fetch failed</code> errors. (#27430) thanks @kevinWangSheng.</li>
|
||||
<li>Android/Node invoke: remove native gateway WebSocket <code>Origin</code> header to avoid false origin rejections, unify invoke command registry/policy/error parsing paths, and keep command availability checks centralized to reduce dispatcher/advertisement drift. (#27257) Thanks @obviyus.</li>
|
||||
<li>Gateway shared-auth scopes: preserve requested operator scopes for shared-token clients when device identity is unavailable, instead of clearing scopes during auth handling. Landed from contributor PR #27498 by @kevinWangSheng. (#27494)</li>
|
||||
<li>Cron/Hooks isolated routing: preserve canonical <code>agent:*</code> session keys in isolated runs so already-qualified keys are not double-prefixed (for example <code>agent:main:main</code> no longer becomes <code>agent:main:agent:main:main</code>). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)</li>
|
||||
<li>Channels/Multi-account config: when adding a non-default channel account to a single-account top-level channel setup, move existing account-scoped top-level single-account values into <code>channels.<channel>.accounts.default</code> before writing the new account so the original account keeps working without duplicated account values at channel root; <code>openclaw doctor --fix</code> now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.</li>
|
||||
<li>iOS/Talk mode: stop injecting the voice directive hint into iOS Talk prompts and remove the Voice Directive Hint setting, reducing model bias toward tool-style TTS directives and keeping relay responses text-first by default. (#27543) thanks @ngutman.</li>
|
||||
<li>CI/Windows: shard the Windows <code>checks-windows</code> test lane into two matrix jobs and honor explicit shard index overrides in <code>scripts/test-parallel.mjs</code> to reduce CI critical-path wall time. (#27234) Thanks @joshavant.</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.2.26/OpenClaw-2026.2.26.zip" length="12796628" type="application/octet-stream" sparkle:edSignature="qqVJfkQS9Q4LCTlGtOyXzORWZWWnOkWyiJ6DVX27oPF8aeUlUyfHrmb51sFiNjSuCJC2xmJW1Mi1CAHl/I1pCw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# OpenClaw iOS App - Comprehensive Audit Report 2026
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~16,244 LOC), `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0 | Swift 6.0 (strict concurrency: complete)
|
||||
**Audit Team:** 5 specialized Opus 4.6 agents (Concurrency, API Modernization, Architecture, UI/UX, Security)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS app is a **well-engineered codebase** that has adopted many 2026 best practices: Swift 6 strict concurrency, the Observation framework (`@Observable`), `NavigationStack`, Keychain credential storage, and TLS certificate pinning. However, the audit identified **9 critical findings**, **17 high findings**, **29 medium findings**, and **25 low findings** across 5 audit domains.
|
||||
|
||||
### Overall Health Score: **B+** (74/100)
|
||||
|
||||
| Domain | Score | Grade | Key Issue |
|
||||
|--------|-------|-------|-----------|
|
||||
| Swift 6 Concurrency | 78/100 | B+ | 3 data race risks, 5 unsafe patterns |
|
||||
| iOS 26 API Modernization | 82/100 | A- | 1 deprecated framework, 4 dead code paths |
|
||||
| Architecture & Code Quality | 62/100 | C+ | 2 god objects, 11.6% test coverage ratio |
|
||||
| UI/UX & Accessibility | 65/100 | C+ | Zero Dynamic Type, zero localization |
|
||||
| Security & Performance | 85/100 | A | No critical vulns, 3 high storage issues |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings (9)
|
||||
|
||||
### Concurrency (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| CON-C1 | `GatewayTLSFingerprintProbe` data race: `objc_sync_enter` with unsynchronized `didFinish`/`session`/`task` reads in `start()` | `Gateway/GatewayConnectionController.swift:992-1058` | Crash/undefined behavior |
|
||||
| CON-C2 | `PhotoCaptureDelegate` & `MovieFileDelegate` unsynchronized `didResume` flag can double-resume `CheckedContinuation` | `Camera/CameraController.swift:260-339` | Fatal crash (debug), UB (release) |
|
||||
| CON-C3 | `GatewayDiagnostics.logWritesSinceCheck` uses `nonisolated(unsafe)` suppressing all compiler race checks | `Gateway/GatewaySettingsStore.swift:358` | Silent data race |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| ARC-C1 | `NodeAppModel` is a 2,787 LOC god object with ~17 responsibilities | `Model/NodeAppModel.swift` | Untestable, unmaintainable |
|
||||
| ARC-C2 | `TalkModeManager` is a 2,153 LOC god object centralizing speech, audio, PTT, and gateway comms | `Voice/TalkModeManager.swift` | Same as above |
|
||||
|
||||
### UI/UX (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| UIX-C1 | `RootCanvas` voiceWakeToast animations ignore `accessibilityReduceMotion` | `RootCanvas.swift:159-167` | Accessibility violation |
|
||||
| UIX-C2 | `TalkOrbOverlay` perpetual pulse animations ignore `accessibilityReduceMotion` | `Voice/TalkOrbOverlay.swift:15-26` | Vestibular disorder risk |
|
||||
| UIX-C3 | `CameraFlashOverlay` has no VoiceOver announcement and no reduced motion check | `RootCanvas.swift:405-429` | Accessibility violation, photosensitivity |
|
||||
|
||||
### API Modernization (1)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| API-C1 | `NetService` usage (deprecated since iOS 16, removed in future SDKs) while `NWBrowser` already used for discovery | `Gateway/GatewayServiceResolver.swift`, `Gateway/GatewayConnectionController.swift:560-657` | Future SDK breakage |
|
||||
|
||||
---
|
||||
|
||||
## High Findings (17)
|
||||
|
||||
### Concurrency (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| CON-H1 | `ScreenRecordService` `UncheckedSendableBox<T>` wraps any T as Sendable, silencing compiler | `Screen/ScreenRecordService.swift:4-11` |
|
||||
| CON-H2 | `WatchMessagingService` `@unchecked Sendable` with `WCSession` property reads unprotected | `Services/WatchMessagingService.swift:23-28` |
|
||||
| CON-H3 | `LocationService` stores `CheckedContinuation` as instance vars with `nonisolated` delegate callbacks hopping to `@MainActor` | `Location/LocationService.swift:13-14` |
|
||||
| CON-H4 | `LiveNotificationCenter` wraps non-Sendable `UNUserNotificationCenter` in `@unchecked Sendable` | `Services/NotificationService.swift:18-58` |
|
||||
| CON-H5 | `NetworkStatusService` is `@unchecked Sendable` but stateless - unnecessary annotation | `Device/NetworkStatusService.swift:5` |
|
||||
|
||||
### Security (3)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| SEC-H1 | TLS fingerprints stored in UserDefaults (backup-extractable trust anchor) | `OpenClawKit/GatewayTLSPinning.swift:19-38` |
|
||||
| SEC-H2 | `KeychainStore` update path doesn't enforce `kSecAttrAccessible` on existing items | `Gateway/KeychainStore.swift:20-37` |
|
||||
| SEC-H3 | Gateway connection metadata (host/port/topology) in UserDefaults | `Gateway/GatewaySettingsStore.swift:170-217` |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| ARC-H1 | 3 oversized files: `GatewayConnectionController` (1,058 LOC), `SettingsTab` (1,032 LOC), `OnboardingWizardView` (884 LOC) | Various |
|
||||
| ARC-H2 | 17 source modules with zero test coverage; 11.6% test LOC ratio | See gap analysis |
|
||||
|
||||
### UI/UX (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| UIX-H1 | Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize`) | All view files |
|
||||
| UIX-H2 | Zero localization infrastructure (all hardcoded English) | All source files |
|
||||
| UIX-H3 | Zero haptic feedback in entire app | All source files |
|
||||
| UIX-H4 | OnboardingWizardView missing accessibility labels on mode selection rows | `Onboarding/OnboardingWizardView.swift` |
|
||||
| UIX-H5 | `GatewayTrustPromptAlert` and `DeepLinkAgentPromptAlert` use deprecated `Alert` API | `Gateway/GatewayTrustPromptAlert.swift` |
|
||||
|
||||
### API Modernization (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| API-H1 | Dead `#available(iOS 15/18)` checks (deployment target is iOS 18.0) | `OpenClawApp.swift:344`, `Camera/CameraController.swift:222-249` |
|
||||
| API-H2 | `UNUserNotificationCenter` callback APIs wrapped in continuations instead of native async | `OpenClawApp.swift:429-462` |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Themes
|
||||
|
||||
### 1. God Object Pattern
|
||||
`NodeAppModel` (2,787 LOC) and `TalkModeManager` (2,153 LOC) together represent **30%** of the entire codebase. Both have `// swiftlint:disable` suppressions acknowledging the problem. This is the single highest-impact improvement opportunity.
|
||||
|
||||
### 2. Inconsistent Synchronization Primitives
|
||||
The codebase uses 4 different synchronization mechanisms: `NSLock` (6 usages), `OSAllocatedUnfairLock` (1 usage), `objc_sync_enter/exit` (1 usage), and `DispatchQueue` serialization (7 usages). Standardizing on `OSAllocatedUnfairLock` + actors would improve consistency and safety.
|
||||
|
||||
### 3. UserDefaults Overuse
|
||||
~70+ direct `UserDefaults.standard` reads/writes with raw string keys across the codebase. TLS fingerprints, gateway metadata, and connection details stored in UserDefaults should be in Keychain. Non-sensitive preferences lack a typed key registry.
|
||||
|
||||
### 4. Missing Accessibility Infrastructure
|
||||
Dynamic Type, localization, and haptic feedback are completely absent. Three views ignore `accessibilityReduceMotion`. This represents the largest gap relative to Apple's 2026 HIG expectations.
|
||||
|
||||
### 5. Test Coverage Gaps
|
||||
11.6% test LOC ratio with 17 untested modules. The gateway reconnect state machine (most complex logic), background lifecycle, onboarding flow, and TalkModeManager have minimal or zero test coverage.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis (Top 15 Gaps)
|
||||
|
||||
| Module | Source LOC | Test LOC | Coverage |
|
||||
|--------|-----------|----------|----------|
|
||||
| `NodeAppModel.swift` | 2,787 | 478 (invoke only) | Partial - reconnect/background/deep links untested |
|
||||
| `TalkModeManager.swift` | 2,153 | 31 (config only) | Minimal |
|
||||
| `GatewayConnectionController.swift` | 1,058 | 226 | Partial - no TLS/Bonjour/autoconnect tests |
|
||||
| `SettingsTab.swift` | 1,032 | 8 (smoke) | Smoke only |
|
||||
| `OnboardingWizardView.swift` | 884 | 0 | None |
|
||||
| `OpenClawApp.swift` | 541 | 0 | None |
|
||||
| `RootCanvas.swift` | 429 | 8 (smoke) | Smoke only |
|
||||
| `GatewayOnboardingView.swift` | 371 | 0 | None |
|
||||
| `WatchMessagingService.swift` | 284 | 0 | None |
|
||||
| `ContactsService.swift` | 210 | 0 | None |
|
||||
| `LocationService.swift` | 177 | 0 | None |
|
||||
| `PhotoLibraryService.swift` | 164 | 0 | None |
|
||||
| `CalendarService.swift` | 135 | 0 | None |
|
||||
| `RemindersService.swift` | 133 | 0 | None |
|
||||
| `MotionService.swift` | 100 | 0 | None |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical Fixes (Immediate)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 1 | Fix `PhotoCaptureDelegate`/`MovieFileDelegate` `didResume` synchronization (CON-C2) | Small | Prevents crashes |
|
||||
| 2 | Fix `GatewayTLSFingerprintProbe` data race (CON-C1) | Small | Prevents undefined behavior |
|
||||
| 3 | Add `accessibilityReduceMotion` checks to `RootCanvas` and `TalkOrbOverlay` (UIX-C1, C2, C3) | Small | Accessibility compliance |
|
||||
| 4 | Replace `nonisolated(unsafe)` in `GatewayDiagnostics` (CON-C3) | Small | Compiler safety |
|
||||
|
||||
### Phase 2: High-Priority Improvements (Next Sprint)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 5 | Move TLS fingerprints to Keychain (SEC-H1) | Medium | Security hardening |
|
||||
| 6 | Fix `KeychainStore` update accessibility enforcement (SEC-H2) | Small | Security correctness |
|
||||
| 7 | Migrate `NetService` to Network framework (API-C1) | Large | Future-proofing |
|
||||
| 8 | Remove dead `#available` checks (API-H1) | Small | Code cleanup |
|
||||
| 9 | Replace `UNUserNotificationCenter` callbacks with async APIs (API-H2) | Small | Modernization |
|
||||
| 10 | Add `@ScaledMetric` Dynamic Type support to key views (UIX-H1) | Medium | Accessibility |
|
||||
|
||||
### Phase 3: Architecture Refactoring (Planned)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 11 | Split `NodeAppModel` into 5-6 focused types (ARC-C1) | Large | Testability, maintainability |
|
||||
| 12 | Split `TalkModeManager` into 3-4 focused types (ARC-C2) | Large | Same |
|
||||
| 13 | Extract `SettingsTab` into section sub-views (ARC-H1) | Medium | Maintainability |
|
||||
| 14 | Create typed UserDefaults key registry | Medium | Type safety |
|
||||
| 15 | Add test coverage for gateway reconnect state machine | Large | Regression safety |
|
||||
| 16 | Add test coverage for background lifecycle management | Medium | Regression safety |
|
||||
|
||||
### Phase 4: Polish & Hardening (Opportunistic)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 17 | Add localization infrastructure with `String(localized:)` (UIX-H2) | Large | International users |
|
||||
| 18 | Add haptic feedback to key interactions (UIX-H3) | Small | UX polish |
|
||||
| 19 | Standardize on `OSAllocatedUnfairLock` across codebase | Small | Consistency |
|
||||
| 20 | Replace Combine `Timer.publish`/`onReceive` with async patterns | Small | Modernization |
|
||||
| 21 | Add keyboard shortcuts for iPad (UIX-M5) | Small | iPad UX |
|
||||
| 22 | Gate `ELEVENLABS_API_KEY` env var behind `#if DEBUG` (SEC-M3) | Small | Security |
|
||||
| 23 | Enforce minimum interval between deep link prompts (SEC-M5) | Small | Security |
|
||||
| 24 | Add HMAC verification to QR setup codes (SEC-M6) | Medium | Security |
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Worth Preserving
|
||||
|
||||
1. **Observation framework adoption** - Zero `ObservableObject` usage; consistent `@Observable` + `@Environment` throughout
|
||||
2. **Protocol-based DI** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities with default implementations
|
||||
3. **Keychain for credentials** - Tokens, passwords, instance IDs stored with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
||||
4. **TLS certificate pinning** - TOFU model with SHA-256 fingerprint verification and user confirmation
|
||||
5. **`CameraController` as actor** - Exemplary Swift concurrency pattern for hardware resource management
|
||||
6. **Dual WebSocket sessions** - Node/operator separation provides good privilege scoping
|
||||
7. **Non-persistent WKWebView** - Canvas prevents session data leakage
|
||||
8. **Swift 6 strict concurrency** - Enabled project-wide with `SWIFT_STRICT_CONCURRENCY: complete`
|
||||
9. **`@Sendable` service protocols** - All service protocols correctly require `Sendable` conformance
|
||||
10. **Deep link confirmation** - Agent deep links require explicit user approval with length limits
|
||||
|
||||
---
|
||||
|
||||
## OWASP Mobile Top 10 Summary
|
||||
|
||||
| Category | Status |
|
||||
|----------|--------|
|
||||
| M1: Improper Credential Usage | PASS |
|
||||
| M2: Inadequate Supply Chain Security | PASS |
|
||||
| M3: Insecure Authentication/Authorization | PASS |
|
||||
| M4: Insufficient Input/Output Validation | PASS |
|
||||
| M5: Insecure Communication | PASS (note: HTTP allowed in web views) |
|
||||
| M6: Inadequate Privacy Controls | PASS (note: location sent over TLS) |
|
||||
| M7: Insufficient Binary Protections | N/A |
|
||||
| M8: Security Misconfiguration | PASS (notes: H-1, H-3) |
|
||||
| M9: Insecure Data Storage | PASS (notes: H-1, H-3, M-2) |
|
||||
| M10: Insufficient Cryptography | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Reports
|
||||
|
||||
Individual audit reports with full code snippets and line-by-line analysis:
|
||||
|
||||
- [`audit-concurrency.md`](./audit-concurrency.md) - Swift 6 strict concurrency (20 findings)
|
||||
- [`audit-api-modernization.md`](./audit-api-modernization.md) - iOS 26 API modernization (19 findings)
|
||||
- [`audit-architecture.md`](./audit-architecture.md) - Architecture & test coverage (16 findings)
|
||||
- [`audit-uiux.md`](./audit-uiux.md) - UI/UX & accessibility (24 findings)
|
||||
- [`audit-security.md`](./audit-security.md) - Security & performance (18 findings)
|
||||
|
||||
---
|
||||
|
||||
*Generated by OpenClaw iOS Audit Team (5x Opus 4.6 agents) on 2026-03-02*
|
||||
@@ -1,6 +1,7 @@
|
||||
import AVFoundation
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
actor CameraController {
|
||||
struct CameraDeviceInfo: Codable, Sendable {
|
||||
@@ -120,7 +121,8 @@ actor CameraController {
|
||||
Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
|
||||
},
|
||||
cameraUnavailableError: CameraError.cameraUnavailable,
|
||||
mapSetupError: Self.mapMovieSetupError) { output in
|
||||
mapSetupError: Self.mapMovieSetupError,
|
||||
operation: { output in
|
||||
var delegate: MovieFileDelegate?
|
||||
let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
|
||||
let d = MovieFileDelegate(cont)
|
||||
@@ -131,7 +133,7 @@ actor CameraController {
|
||||
// Transcode .mov -> .mp4 for easier downstream handling.
|
||||
try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
|
||||
return try Data(contentsOf: mp4URL)
|
||||
}
|
||||
})
|
||||
return (
|
||||
format: format.rawValue,
|
||||
base64: data.base64EncodedString(),
|
||||
@@ -259,7 +261,7 @@ actor CameraController {
|
||||
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let continuation: CheckedContinuation<Data, Error>
|
||||
private var didResume = false
|
||||
private let resumed = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(_ continuation: CheckedContinuation<Data, Error>) {
|
||||
self.continuation = continuation
|
||||
@@ -270,8 +272,12 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?
|
||||
) {
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
return was
|
||||
}
|
||||
guard !alreadyResumed else { return }
|
||||
|
||||
if let error {
|
||||
self.continuation.resume(throwing: error)
|
||||
@@ -300,15 +306,19 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
error: Error?
|
||||
) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
return was
|
||||
}
|
||||
guard !alreadyResumed else { return }
|
||||
self.continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
||||
private let continuation: CheckedContinuation<URL, Error>
|
||||
private var didResume = false
|
||||
private let resumed = OSAllocatedUnfairLock(initialState: false)
|
||||
|
||||
init(_ continuation: CheckedContinuation<URL, Error>) {
|
||||
self.continuation = continuation
|
||||
@@ -320,8 +330,12 @@ private final class MovieFileDelegate: NSObject, AVCaptureFileOutputRecordingDel
|
||||
from connections: [AVCaptureConnection],
|
||||
error: Error?)
|
||||
{
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
let alreadyResumed = self.resumed.withLock { old in
|
||||
let was = old
|
||||
old = true
|
||||
return was
|
||||
}
|
||||
guard !alreadyResumed else { return }
|
||||
|
||||
if let error {
|
||||
let ns = error as NSError
|
||||
|
||||
@@ -9,6 +9,7 @@ import Darwin
|
||||
import OpenClawKit
|
||||
import Network
|
||||
import Observation
|
||||
import os
|
||||
import Photos
|
||||
import ReplayKit
|
||||
import Security
|
||||
@@ -990,12 +991,16 @@ extension GatewayConnectionController {
|
||||
#endif
|
||||
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private struct ProbeState {
|
||||
var didFinish = false
|
||||
var session: URLSession?
|
||||
var task: URLSessionWebSocketTask?
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let timeoutSeconds: Double
|
||||
private let onComplete: (String?) -> Void
|
||||
private var didFinish = false
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
private let state = OSAllocatedUnfairLock(initialState: ProbeState())
|
||||
|
||||
init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) {
|
||||
self.url = url
|
||||
@@ -1008,9 +1013,11 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
||||
config.timeoutIntervalForRequest = self.timeoutSeconds
|
||||
config.timeoutIntervalForResource = self.timeoutSeconds
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
self.session = session
|
||||
let task = session.webSocketTask(with: self.url)
|
||||
self.task = task
|
||||
self.state.withLock { s in
|
||||
s.session = session
|
||||
s.task = task
|
||||
}
|
||||
task.resume()
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
|
||||
@@ -1036,12 +1043,18 @@ private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @u
|
||||
}
|
||||
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self)
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.session?.invalidateAndCancel()
|
||||
let (shouldComplete, taskToCancel, sessionToInvalidate) = self.state.withLock { s -> (Bool, URLSessionWebSocketTask?, URLSession?) in
|
||||
guard !s.didFinish else { return (false, nil, nil) }
|
||||
s.didFinish = true
|
||||
let task = s.task
|
||||
let session = s.session
|
||||
s.task = nil
|
||||
s.session = nil
|
||||
return (true, task, session)
|
||||
}
|
||||
guard shouldComplete else { return }
|
||||
taskToCancel?.cancel(with: .goingAway, reason: nil)
|
||||
sessionToInvalidate?.invalidateAndCancel()
|
||||
self.onComplete(fingerprint)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ enum GatewaySettingsStore {
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
|
||||
private static let lastGatewayConnectionAccount = "lastConnection"
|
||||
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey."
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
@@ -140,11 +141,20 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String {
|
||||
private enum LastGatewayKind: String, Codable {
|
||||
case manual
|
||||
case discovered
|
||||
}
|
||||
|
||||
/// JSON-serializable envelope stored as a single Keychain entry.
|
||||
private struct LastGatewayConnectionData: Codable {
|
||||
var kind: LastGatewayKind
|
||||
var stableID: String
|
||||
var useTLS: Bool
|
||||
var host: String?
|
||||
var port: Int?
|
||||
}
|
||||
|
||||
static func loadTalkProviderApiKey(provider: String) -> String? {
|
||||
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
|
||||
let account = self.talkProviderApiKeyAccount(providerId: providerId)
|
||||
@@ -168,47 +178,93 @@ enum GatewaySettingsStore {
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
|
||||
self.saveLastGatewayConnectionData(payload)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: .discovered, stableID: stableID, useTLS: useTLS)
|
||||
self.saveLastGatewayConnectionData(payload)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> LastGatewayConnection? {
|
||||
// Migrate legacy UserDefaults entries on first access.
|
||||
self.migrateLastGatewayFromUserDefaultsIfNeeded()
|
||||
|
||||
guard let json = KeychainStore.loadString(
|
||||
service: self.gatewayService, account: self.lastGatewayConnectionAccount),
|
||||
let data = json.data(using: .utf8),
|
||||
let stored = try? JSONDecoder().decode(LastGatewayConnectionData.self, from: data)
|
||||
else { return nil }
|
||||
|
||||
let stableID = stored.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
|
||||
if stored.kind == .discovered {
|
||||
return .discovered(stableID: stableID, useTLS: stored.useTLS)
|
||||
}
|
||||
|
||||
let host = (stored.host ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let port = stored.port ?? 0
|
||||
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
|
||||
return .manual(host: host, port: port, useTLS: stored.useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
||||
// Clean up any legacy UserDefaults entries.
|
||||
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func saveLastGatewayConnectionData(_ payload: LastGatewayConnectionData) -> Bool {
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return false }
|
||||
return KeychainStore.saveString(
|
||||
json, service: self.gatewayService, account: self.lastGatewayConnectionAccount)
|
||||
}
|
||||
|
||||
/// Migrate legacy UserDefaults gateway.last.* keys into a single Keychain entry.
|
||||
private static func migrateLastGatewayFromUserDefaultsIfNeeded() {
|
||||
let defaults = UserDefaults.standard
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
guard !stableID.isEmpty else { return }
|
||||
|
||||
// Already migrated if Keychain entry exists.
|
||||
if KeychainStore.loadString(
|
||||
service: self.gatewayService, account: self.lastGatewayConnectionAccount) != nil
|
||||
{
|
||||
// Clean up legacy keys.
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
return
|
||||
}
|
||||
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
|
||||
|
||||
if kind == .discovered {
|
||||
return .discovered(stableID: stableID, useTLS: useTLS)
|
||||
}
|
||||
|
||||
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let port = defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int
|
||||
|
||||
// Back-compat: older builds persisted manual-style host/port without a kind marker.
|
||||
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
|
||||
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
let payload = LastGatewayConnectionData(
|
||||
kind: kind, stableID: stableID, useTLS: useTLS,
|
||||
host: kind == .manual ? host : nil,
|
||||
port: kind == .manual ? port : nil)
|
||||
guard self.saveLastGatewayConnectionData(payload) else { return }
|
||||
self.removeLastGatewayDefaults(defaults)
|
||||
}
|
||||
|
||||
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
|
||||
private static func removeLastGatewayDefaults(_ defaults: UserDefaults) {
|
||||
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
@@ -355,9 +411,15 @@ enum GatewayDiagnostics {
|
||||
private static let maxLogBytes: Int64 = 512 * 1024
|
||||
private static let keepLogBytes: Int64 = 256 * 1024
|
||||
private static let logSizeCheckEveryWrites = 50
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
|
||||
private static let isoFormatter: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static var fileURL: URL? {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
||||
.appendingPathComponent("openclaw-gateway.log")
|
||||
}
|
||||
|
||||
@@ -404,32 +466,41 @@ enum GatewayDiagnostics {
|
||||
}
|
||||
}
|
||||
|
||||
private static func applyFileProtection(url: URL) {
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
static func bootstrap() {
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let timestamp = self.isoFormatter.string(from: Date())
|
||||
let line = "[\(timestamp)] gateway diagnostics started\n"
|
||||
if let data = line.data(using: .utf8) {
|
||||
self.appendToLog(url: url, data: data)
|
||||
self.applyFileProtection(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func log(_ message: String) {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let timestamp = formatter.string(from: Date())
|
||||
let timestamp = self.isoFormatter.string(from: Date())
|
||||
let line = "[\(timestamp)] \(message)"
|
||||
logger.info("\(line, privacy: .public)")
|
||||
|
||||
guard let url = fileURL else { return }
|
||||
queue.async {
|
||||
self.logWritesSinceCheck += 1
|
||||
if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites {
|
||||
self.logWritesSinceCheck = 0
|
||||
let shouldTruncate = self.logWritesSinceCheck.withLock { count in
|
||||
count += 1
|
||||
if count >= self.logSizeCheckEveryWrites {
|
||||
count = 0
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if shouldTruncate {
|
||||
self.truncateLogIfNeeded(url: url)
|
||||
}
|
||||
let entry = line + "\n"
|
||||
|
||||
@@ -18,6 +18,9 @@ enum KeychainStore {
|
||||
}
|
||||
|
||||
static func saveString(_ value: String, service: String, account: String) -> Bool {
|
||||
// Delete-then-add ensures kSecAttrAccessible is always applied.
|
||||
// SecItemUpdate cannot change the accessibility level of an existing item,
|
||||
// so a stale item created with a weaker policy would retain it on update.
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
@@ -25,10 +28,7 @@ enum KeychainStore {
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
|
||||
let update: [String: Any] = [kSecValueData as String: data]
|
||||
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
if status == errSecSuccess { return true }
|
||||
if status != errSecItemNotFound { return false }
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
|
||||
@@ -21,7 +21,7 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
self.manager
|
||||
}
|
||||
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Error>? {
|
||||
var locationRequestContinuation: CheckedContinuation<CLLocation, Swift.Error>? {
|
||||
get { self.locationContinuation }
|
||||
set { self.locationContinuation = newValue }
|
||||
}
|
||||
@@ -65,9 +65,10 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs,
|
||||
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
|
||||
request: { try await self.requestLocationOnce() },
|
||||
withTimeout: { timeoutMs, operation in
|
||||
try await self.withTimeout(timeoutMs: timeoutMs, operation: operation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
|
||||
|
||||
@@ -2591,8 +2591,8 @@ extension NodeAppModel {
|
||||
"agent deep link rejected: unkeyed message too long chars=\(message.count, privacy: .public)")
|
||||
return
|
||||
}
|
||||
if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 1.0 {
|
||||
self.deepLinkLogger.debug("agent deep link prompt throttled")
|
||||
if Date().timeIntervalSince(self.lastAgentDeepLinkPromptAt) < 5.0 {
|
||||
self.deepLinkLogger.debug("agent deep link prompt rate-limited (min 5 s interval)")
|
||||
return
|
||||
}
|
||||
self.lastAgentDeepLinkPromptAt = Date()
|
||||
|
||||
@@ -302,6 +302,7 @@ private struct ManualEntryStep: View {
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func gatewayConnectionStatusLines(
|
||||
appModel: NodeAppModel,
|
||||
gatewayController: GatewayConnectionController) -> [String]
|
||||
@@ -309,6 +310,7 @@ private func gatewayConnectionStatusLines(
|
||||
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resetGatewayConnectionState(
|
||||
appModel: NodeAppModel,
|
||||
connectStatusText: inout String?,
|
||||
@@ -319,6 +321,7 @@ private func resetGatewayConnectionState(
|
||||
connectingGatewayID = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
private func gatewayConnectionStatusSection(
|
||||
appModel: NodeAppModel,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AVFoundation
|
||||
import OpenClawKit
|
||||
import ReplayKit
|
||||
|
||||
final class ScreenRecordService: @unchecked Sendable {
|
||||
|
||||
@@ -72,6 +72,9 @@ final class TalkModeManager: NSObject {
|
||||
private var mainSessionKey: String = "main"
|
||||
private var fallbackVoiceId: String?
|
||||
private var lastPlaybackWasPCM: Bool = false
|
||||
/// Set when the ElevenLabs API rejects PCM format (e.g. 403 subscription_required).
|
||||
/// Once set, all subsequent requests in this session use MP3 instead of re-trying PCM.
|
||||
private var pcmFormatUnavailable: Bool = false
|
||||
var pcmPlayer: PCMStreamingAudioPlaying = PCMStreamingAudioPlayer.shared
|
||||
var mp3Player: StreamingAudioPlaying = StreamingAudioPlayer.shared
|
||||
|
||||
@@ -987,9 +990,12 @@ final class TalkModeManager: NSObject {
|
||||
self.logger.warning("unknown voice alias \(requestedVoice ?? "?", privacy: .public)")
|
||||
}
|
||||
|
||||
let resolvedKey =
|
||||
(self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil) ??
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
let configuredKey = self.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? self.apiKey : nil
|
||||
#if DEBUG
|
||||
let resolvedKey = configuredKey ?? ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
#else
|
||||
let resolvedKey = configuredKey
|
||||
#endif
|
||||
let apiKey = resolvedKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let preferredVoice = resolvedVoice ?? self.currentVoiceId ?? self.defaultVoiceId
|
||||
let voiceId: String? = if let apiKey, !apiKey.isEmpty {
|
||||
@@ -1004,7 +1010,8 @@ final class TalkModeManager: NSObject {
|
||||
let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100")
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(
|
||||
requestedOutputFormat ?? self.effectiveDefaultOutputFormat)
|
||||
if outputFormat == nil, let requestedOutputFormat {
|
||||
self.logger.warning(
|
||||
"talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)")
|
||||
@@ -1051,8 +1058,9 @@ final class TalkModeManager: NSObject {
|
||||
self.lastPlaybackWasPCM = true
|
||||
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.pcmFormatUnavailable = true
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
@@ -1388,7 +1396,7 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func resolveIncrementalPrefetchOutputFormat(context: IncrementalSpeechContext) -> String? {
|
||||
if TalkTTSValidation.pcmSampleRate(from: context.outputFormat) != nil {
|
||||
return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
return ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
}
|
||||
return context.outputFormat
|
||||
}
|
||||
@@ -1477,7 +1485,8 @@ final class TalkModeManager: NSObject {
|
||||
let desiredOutputFormat = (directive?.outputFormat ?? self.defaultOutputFormat)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestedOutputFormat = (desiredOutputFormat?.isEmpty == false) ? desiredOutputFormat : nil
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(requestedOutputFormat ?? "pcm_44100")
|
||||
let outputFormat = ElevenLabsTTSClient.validatedOutputFormat(
|
||||
requestedOutputFormat ?? self.effectiveDefaultOutputFormat)
|
||||
if outputFormat == nil, let requestedOutputFormat {
|
||||
self.logger.warning(
|
||||
"talk output_format unsupported for local playback: \(requestedOutputFormat, privacy: .public)")
|
||||
@@ -1528,6 +1537,11 @@ final class TalkModeManager: NSObject {
|
||||
latencyTier: TalkTTSValidation.validatedLatencyTier(context.directive?.latencyTier))
|
||||
}
|
||||
|
||||
/// Returns `mp3_44100_128` when the API has already rejected PCM, otherwise `pcm_44100`.
|
||||
private var effectiveDefaultOutputFormat: String {
|
||||
self.pcmFormatUnavailable ? "mp3_44100_128" : "pcm_44100"
|
||||
}
|
||||
|
||||
private static func makeBufferedAudioStream(chunks: [Data]) -> AsyncThrowingStream<Data, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
for chunk in chunks {
|
||||
@@ -1583,8 +1597,9 @@ final class TalkModeManager: NSObject {
|
||||
var playback = await self.pcmPlayer.play(stream: stream, sampleRate: sampleRate)
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
self.logger.warning("pcm playback failed; retrying mp3")
|
||||
self.pcmFormatUnavailable = true
|
||||
self.lastPlaybackWasPCM = false
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100")
|
||||
let mp3Format = ElevenLabsTTSClient.validatedOutputFormat("mp3_44100_128")
|
||||
let mp3Stream = client.streamSynthesize(
|
||||
voiceId: voiceId,
|
||||
request: self.makeIncrementalTTSRequest(
|
||||
@@ -1920,6 +1935,7 @@ extension TalkModeManager {
|
||||
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
self.pcmFormatUnavailable = false
|
||||
do {
|
||||
let res = try await gateway.request(
|
||||
method: "talk.config",
|
||||
|
||||
@@ -441,13 +441,6 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: AVAudioSession.RecordPermission) -> String
|
||||
{
|
||||
self.deniedByDefaultPermissionMessage(kind: kind, isUndetermined: status == .undetermined)
|
||||
}
|
||||
|
||||
private static func permissionMessage(
|
||||
kind: String,
|
||||
status: SFSpeechRecognizerAuthorizationStatus) -> String
|
||||
@@ -466,7 +459,7 @@ final class VoiceWakeManager: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
|
||||
private nonisolated static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
|
||||
if isUndetermined {
|
||||
return "\(kind) permission not granted"
|
||||
}
|
||||
|
||||
@@ -71,18 +71,37 @@ import UIKit
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
withUserDefaults([:]) {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
if let prior {
|
||||
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
} else {
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
}
|
||||
}
|
||||
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
|
||||
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
|
||||
withUserDefaults([
|
||||
"gateway.last.kind": "manual",
|
||||
"gateway.last.host": "",
|
||||
|
||||
@@ -27,6 +27,7 @@ private let lastGatewayDefaultsKeys = [
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
private let lastGatewayKeychainEntry = KeychainEntry(service: gatewayService, account: "lastConnection")
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -84,9 +85,13 @@ private func withBootstrapSnapshots(_ body: () -> Void) {
|
||||
body()
|
||||
}
|
||||
|
||||
private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
let snapshot = snapshotDefaults(lastGatewayDefaultsKeys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
private func withLastGatewaySnapshot(_ body: () -> Void) {
|
||||
let defaultsSnapshot = snapshotDefaults(lastGatewayDefaultsKeys)
|
||||
let keychainSnapshot = snapshotKeychain([lastGatewayKeychainEntry])
|
||||
defer {
|
||||
restoreDefaults(defaultsSnapshot)
|
||||
restoreKeychain(keychainSnapshot)
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
@@ -135,7 +140,7 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
withLastGatewaySnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
@@ -147,28 +152,24 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
@Test func lastGateway_discoveredOverwritesManual() {
|
||||
withLastGatewaySnapshot {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "10.0.0.99",
|
||||
port: 18789,
|
||||
useTLS: true,
|
||||
stableID: "manual|10.0.0.99|18789")
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
|
||||
withLastGatewayDefaultsSnapshot {
|
||||
@Test func lastGateway_migratesFromUserDefaults() {
|
||||
withLastGatewaySnapshot {
|
||||
// Clear Keychain entry and plant legacy UserDefaults values.
|
||||
applyKeychain([lastGatewayKeychainEntry: nil])
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
@@ -179,6 +180,11 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
|
||||
// Legacy keys should be cleaned up after migration.
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.stableID") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
478
apps/ios/audit-api-modernization.md
Normal file
478
apps/ios/audit-api-modernization.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# iOS API Modernization Audit Report
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** API Modernization Expert (Claude Opus 4.6)
|
||||
**Scope:** All Swift source files in `apps/ios/Sources/`, `apps/ios/WatchExtension/Sources/`, and `apps/ios/ShareExtension/`
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0
|
||||
**Swift Version:** 6.0 (strict concurrency: complete)
|
||||
**Xcode Version:** 16.0
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS codebase is well-maintained and has already adopted many modern Swift and iOS patterns. The Observation framework (`@Observable`, `@Bindable`, `@Environment(ModelType.self)`) is used consistently throughout. `NavigationStack` is used instead of the deprecated `NavigationView`. Swift 6 strict concurrency is enabled project-wide.
|
||||
|
||||
However, there are several areas where deprecated APIs remain in use, unnecessary availability checks exist (dead code given iOS 18.0 deployment target), and legacy callback-based APIs are wrapped in continuations where native async alternatives are available.
|
||||
|
||||
### Summary by Severity
|
||||
|
||||
| Severity | Count | Description |
|
||||
|----------|-------|-------------|
|
||||
| Critical | 1 | Deprecated `NetService` usage (removed in future SDKs) |
|
||||
| High | 4 | Dead availability-check code, legacy callback wrapping |
|
||||
| Medium | 8 | Callback APIs with async alternatives, legacy patterns |
|
||||
| Low | 6 | Minor modernization opportunities, style improvements |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `NetService` Usage (Deprecated Since iOS 16)
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/GatewayServiceResolver.swift` (entire file)
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift` (lines ~560-657)
|
||||
|
||||
**Current Code:**
|
||||
`GatewayServiceResolver` is built entirely on `NetService` and `NetServiceDelegate`, which have been deprecated since iOS 16. `GatewayConnectionController` uses `NetService` for Bonjour resolution in `resolveBonjourServiceToHostPort`.
|
||||
|
||||
**Risk:** Apple may remove `NetService` entirely in a future SDK. The app already uses `NWBrowser` (Network framework) for discovery in `GatewayDiscoveryModel.swift`, creating an inconsistency where discovery uses the modern API but resolution falls back to the deprecated one.
|
||||
|
||||
**Recommended Replacement:** Migrate to `NWConnection` for TCP connection establishment and use the endpoint information from `NWBrowser` results directly, eliminating the need for a separate `NetService`-based resolver. The `NWBrowser.Result` already provides `NWEndpoint` values that can be used with `NWConnection` without resolution.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Unnecessary `#available(iOS 15.0, *)` Check
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, line 344
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 15.0, *) { ... }
|
||||
```
|
||||
|
||||
**Issue:** The deployment target is iOS 18.0, so this check is always true. The code inside the `#available` block executes unconditionally, and the compiler may warn about this.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and keep only the body.
|
||||
|
||||
### H-2: Dead `AVAssetExportSession` Fallback Code
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift`, lines ~222-249
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
|
||||
try await exportSession.export(to: fileURL, as: .mp4)
|
||||
} else {
|
||||
exportSession.outputURL = fileURL
|
||||
exportSession.outputFileType = .mp4
|
||||
await exportSession.export()
|
||||
// ...legacy error check...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `else` branch is dead code since the deployment target is iOS 18.0. The `#available` check is always true.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and the `else` branch entirely. Use only the modern `export(to:as:)` API.
|
||||
|
||||
### H-3: Callback-Based `UNUserNotificationCenter` APIs Wrapped in Continuations
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, lines ~429-462
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let settings = await withCheckedContinuation { cont in
|
||||
center.getNotificationSettings { settings in
|
||||
cont.resume(returning: settings)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` has had native async APIs since iOS 15:
|
||||
- `center.notificationSettings()` (replaces `getNotificationSettings`)
|
||||
- `center.notificationCategories()` (replaces `getNotificationCategories`)
|
||||
- `try await center.add(request)` (replaces `add(_:completionHandler:)`)
|
||||
|
||||
The Watch app (`WatchInboxStore.swift`, line 161) already correctly uses the modern async pattern: `await center.notificationSettings()`.
|
||||
|
||||
**Recommended Fix:** Replace all `withCheckedContinuation` wrappers around `UNUserNotificationCenter` with their native async equivalents.
|
||||
|
||||
### H-4: `NSItemProvider.loadItem` Callback Pattern in Share Extension
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, lines ~501-547
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
await withCheckedContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
|
||||
// ...
|
||||
continuation.resume(returning: ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSItemProvider` has had modern async alternatives since iOS 16:
|
||||
- `try await provider.loadItem(forTypeIdentifier:)` for basic loading
|
||||
- `try await provider.loadDataRepresentation(for:)` with `UTType` parameter
|
||||
- `try await provider.loadFileRepresentation(for:)`
|
||||
|
||||
Three separate methods (`loadURLValue`, `loadTextValue`, `loadDataValue`) all wrap callbacks in continuations.
|
||||
|
||||
**Recommended Fix:** Adopt the modern `NSItemProvider` async APIs, using `UTType` parameters instead of string identifiers where possible.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `CLLocationManager` Delegate Pattern vs Modern `CLLocationUpdate` API
|
||||
|
||||
**File:** `apps/ios/Sources/Location/LocationService.swift` (entire file)
|
||||
|
||||
**Current Code:** Uses `CLLocationManagerDelegate` with:
|
||||
- `startUpdatingLocation()` / `stopUpdatingLocation()`
|
||||
- `startMonitoringSignificantLocationChanges()`
|
||||
- `requestWhenInUseAuthorization()` / `requestAlwaysAuthorization()`
|
||||
- `locationManager(_:didUpdateLocations:)` delegate callback
|
||||
|
||||
**Modern Alternative (iOS 17+):**
|
||||
- `CLLocationUpdate.liveUpdates()` async sequence for continuous location
|
||||
- `CLMonitor` for region monitoring and significant location changes
|
||||
- `CLLocationManager.requestWhenInUseAuthorization()` still required for authorization, but updates are consumed via async sequences
|
||||
|
||||
**Impact:** The delegate pattern works but requires more boilerplate and is harder to compose with async/await code.
|
||||
|
||||
**Recommended Fix:** Migrate `startLocationUpdates` to use `CLLocationUpdate.liveUpdates()` and consider `CLMonitor` for significant location changes. Keep the authorization request methods as-is (no async alternative for those).
|
||||
|
||||
### M-2: `CMMotionActivityManager` and `CMPedometer` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Motion/MotionService.swift`, lines 23-81
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
activityManager.queryActivityStarting(from: startDate, to: endDate, to: OperationQueue.main) { activities, error in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** CoreMotion APIs still use callbacks; there are no native async versions. However, wrapping in `withCheckedThrowingContinuation` is currently the correct approach.
|
||||
|
||||
**Recommended Fix:** No change needed at this time. Monitor for async CoreMotion APIs in future SDK releases.
|
||||
|
||||
### M-3: `EKEventStore.fetchReminders` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Reminders/RemindersService.swift`, lines 20-45
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { reminders in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** EventKit still uses callbacks for `fetchReminders`. The continuation wrapper is the correct approach for now.
|
||||
|
||||
**Recommended Fix:** No change needed. This is the standard pattern for callback-based EventKit APIs.
|
||||
|
||||
### M-4: `PHImageManager.requestImage` Synchronous Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`, line ~82
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
// ...
|
||||
imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: options) { image, _ in
|
||||
resultImage = image
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Uses `isSynchronous = true` which blocks the calling thread. Modern iOS apps should prefer async image loading. Consider using `PHImageManager`'s async image loading or the newer `PHPickerViewController` patterns for user-initiated selection.
|
||||
|
||||
**Recommended Fix:** If this code runs on a background thread (inside an actor), the synchronous pattern is acceptable for simplicity. Consider wrapping in a continuation if thread blocking becomes an issue.
|
||||
|
||||
### M-5: `NotificationCenter` Observer Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 105-113
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: UserDefaults.standard,
|
||||
queue: .main,
|
||||
using: { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.handleUserDefaultsDidChange()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Modern Alternative (iOS 15+):**
|
||||
```swift
|
||||
// Use async notification sequence
|
||||
for await _ in NotificationCenter.default.notifications(named: UserDefaults.didChangeNotification) {
|
||||
self.handleUserDefaultsDidChange()
|
||||
}
|
||||
```
|
||||
|
||||
**Also in:** `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (uses `onReceive` with Combine publisher -- see M-8).
|
||||
|
||||
**Recommended Fix:** Replace callback-based observers with `NotificationCenter.default.notifications(named:)` async sequences in a `.task` modifier or dedicated Task.
|
||||
|
||||
### M-6: `DispatchQueue.asyncAfter` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/TCPProbe.swift`, line 39
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, line ~1016
|
||||
- `apps/ios/ShareExtension/ShareViewController.swift`, line 142
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
```
|
||||
|
||||
**Issue:** `DispatchQueue.asyncAfter` is a legacy GCD pattern. In Swift concurrency, `Task.sleep(nanoseconds:)` or `Task.sleep(for:)` is preferred. However, in `TCPProbe`, the GCD pattern is used within an `NWConnection` state handler context where a DispatchQueue is already in use, making it acceptable.
|
||||
|
||||
**Recommended Fix:**
|
||||
- `TCPProbe.swift`: Acceptable as-is (NWConnection requires a DispatchQueue).
|
||||
- `GatewayConnectionController.swift`: Replace with `Task.sleep` pattern.
|
||||
- `ShareViewController.swift`: Replace with `Task.sleep` + `MainActor.run`.
|
||||
|
||||
### M-7: `objc_sync_enter`/`objc_sync_exit` and `objc_setAssociatedObject`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, lines ~1039-1040, ~653
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
objc_sync_enter(connection)
|
||||
// ...
|
||||
objc_sync_exit(connection)
|
||||
```
|
||||
and
|
||||
```swift
|
||||
objc_setAssociatedObject(service, &resolvedKey, resolvedBox, .OBJC_ASSOCIATION_RETAIN)
|
||||
```
|
||||
|
||||
**Issue:** These are Objective-C runtime patterns. Swift has modern alternatives:
|
||||
- `OSAllocatedUnfairLock` (iOS 16+) or `Mutex` (proposed) for synchronization
|
||||
- Property wrappers or Swift-native patterns for associated state
|
||||
|
||||
Note: `TCPProbe.swift` correctly uses `OSAllocatedUnfairLock` already.
|
||||
|
||||
**Recommended Fix:** Replace `objc_sync_enter`/`objc_sync_exit` with `OSAllocatedUnfairLock`. For `objc_setAssociatedObject`, this will naturally be eliminated when migrating away from `NetService` (see C-1).
|
||||
|
||||
### M-8: Combine `Timer.publish` and `onReceive` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Onboarding/OnboardingWizardView.swift`, line ~72 (`Timer.publish`)
|
||||
- `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (`.onReceive(NotificationCenter.default.publisher(...))`)
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
@State private var autoAdvanceTimer = Timer.publish(every: 5.5, on: .main, in: .common).autoconnect()
|
||||
// ...
|
||||
.onReceive(self.autoAdvanceTimer) { _ in ... }
|
||||
```
|
||||
|
||||
**Issue:** `Timer.publish` is a Combine pattern. Modern SwiftUI alternatives include:
|
||||
- `.task { while !Task.isCancelled { ... try? await Task.sleep(...) } }` for recurring timers
|
||||
- `TimelineView(.periodic(from:, by:))` for UI-driven periodic updates
|
||||
|
||||
**Recommended Fix:** Replace `Timer.publish` with a `.task`-based loop using `Task.sleep`. Replace `onReceive(NotificationCenter.default.publisher(...))` with `.task` + `NotificationCenter.default.notifications(named:)` async sequence.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@unchecked Sendable` on `WatchConnectivityReceiver`
|
||||
|
||||
**File:** `apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift`, line 21
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { ... }
|
||||
```
|
||||
|
||||
**Issue:** `@unchecked Sendable` bypasses the compiler's sendability checks. The class holds a `WCSession?` and `WatchInboxStore` reference. Since `WatchInboxStore` is `@MainActor @Observable`, the receiver should ideally be restructured to use actor isolation or be marked `@MainActor`.
|
||||
|
||||
**Recommended Fix:** Consider making `WatchConnectivityReceiver` `@MainActor` or using an actor to protect shared state. The `WCSessionDelegate` methods dispatch to `@MainActor` already.
|
||||
|
||||
### L-2: `@unchecked Sendable` on `ScreenRecordService`
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift`
|
||||
|
||||
**Current Code:** Uses `@unchecked Sendable` with manual `NSLock`-based `CaptureState` synchronization.
|
||||
|
||||
**Issue:** Manual lock-based synchronization is error-prone. An actor would provide compiler-verified thread safety.
|
||||
|
||||
**Recommended Fix:** Consider converting `ScreenRecordService` to an actor, or at minimum replace `NSLock` with `OSAllocatedUnfairLock` for consistency with other parts of the codebase (e.g., `TCPProbe.swift`).
|
||||
|
||||
### L-3: `NSLock` Usage in `AudioBufferQueue`
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 15-38
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private final class AudioBufferQueue: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSLock` is a valid synchronization primitive but `OSAllocatedUnfairLock` (iOS 16+) is more efficient and is already used elsewhere in the codebase.
|
||||
|
||||
**Recommended Fix:** Replace `NSLock` with `OSAllocatedUnfairLock` for consistency and performance. Note: this class is intentionally `@unchecked Sendable` because it runs on a realtime audio thread where actor isolation is not appropriate -- the manual lock pattern is correct here; just the lock type could be modernized.
|
||||
|
||||
### L-4: `DateFormatter` Usage Instead of `.formatted()`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift`, lines 49-67
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
```
|
||||
|
||||
**Issue:** Since iOS 15, Swift provides `Date.formatted()` with `FormatStyle` which is more type-safe and concise. The `WatchInboxView.swift` already uses the modern pattern: `updatedAt.formatted(date: .omitted, time: .shortened)`.
|
||||
|
||||
**Recommended Fix:** Replace `DateFormatter` with `Date.formatted(.dateTime.hour().minute().second())` for the time format and `Date.ISO8601FormatStyle` for ISO formatting.
|
||||
|
||||
### L-5: `UIScreen.main.bounds` Usage
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, line 31
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
|
||||
```
|
||||
|
||||
**Issue:** `UIScreen.main` is deprecated in iOS 16. In an extension context, `view.window?.windowScene?.screen` may not be available at `viewDidLoad` time, so the deprecation is harder to address here.
|
||||
|
||||
**Recommended Fix:** Since this is a share extension with limited lifecycle, this is acceptable. If refactoring, consider using trait collection or a fixed width, since the system manages extension sizing.
|
||||
|
||||
### L-6: String-Based `NSSortDescriptor` Key Path
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
NSSortDescriptor(key: "creationDate", ascending: false)
|
||||
```
|
||||
|
||||
**Issue:** String-based key paths are not type-safe. While Photos framework requires `NSSortDescriptor`, this is a known limitation of the framework.
|
||||
|
||||
**Recommended Fix:** No change needed. The Photos framework API requires `NSSortDescriptor` with string keys.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings (Already Modern)
|
||||
|
||||
The following modern patterns are already correctly adopted throughout the codebase:
|
||||
|
||||
| Pattern | Status | Files |
|
||||
|---------|--------|-------|
|
||||
| `@Observable` (Observation framework) | Adopted | `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `ScreenController`, `VoiceWakeManager`, `TalkModeManager`, `WatchInboxStore` |
|
||||
| `@Environment(ModelType.self)` | Adopted | All views consistently use this pattern |
|
||||
| `@Bindable` for two-way bindings | Adopted | `WatchInboxView`, various settings views |
|
||||
| `NavigationStack` (not `NavigationView`) | Adopted | All navigation uses `NavigationStack` |
|
||||
| Modern `onChange(of:) { _, newValue in }` | Adopted | All `onChange` modifiers use the two-parameter variant |
|
||||
| `NWBrowser` (Network framework) | Adopted | `GatewayDiscoveryModel` for Bonjour discovery |
|
||||
| `NWPathMonitor` (Network framework) | Adopted | `NetworkStatusService` |
|
||||
| `DataScannerViewController` (VisionKit) | Adopted | `QRScannerView` for QR code scanning |
|
||||
| `PhotosPicker` (PhotosUI) | Adopted | `OnboardingWizardView` |
|
||||
| `OSAllocatedUnfairLock` | Adopted | `TCPProbe` |
|
||||
| Swift 6 strict concurrency | Adopted | Project-wide `SWIFT_STRICT_CONCURRENCY: complete` |
|
||||
| `actor` isolation | Adopted | `CameraController` uses `actor` |
|
||||
| `@ObservationIgnored` | Adopted | `NodeAppModel` for non-tracked properties |
|
||||
| `OSLog` / `Logger` | Adopted | Throughout the codebase |
|
||||
| `async`/`await` | Adopted | Pervasive throughout the codebase |
|
||||
| No `ObservableObject` / `@StateObject` | Correct | No legacy `ObservableObject` usage found |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical (Immediate)
|
||||
1. **Migrate `NetService` to Network framework** (C-1) -- `GatewayServiceResolver` and `GatewayConnectionController` Bonjour resolution
|
||||
|
||||
### Phase 2: High (Next Sprint)
|
||||
2. **Remove dead `#available` checks** (H-1, H-2) -- `OpenClawApp.swift`, `CameraController.swift`
|
||||
3. **Replace `UNUserNotificationCenter` callback wrappers** (H-3) -- `OpenClawApp.swift`
|
||||
4. **Modernize `NSItemProvider` loading in Share Extension** (H-4) -- `ShareViewController.swift`
|
||||
|
||||
### Phase 3: Medium (Planned)
|
||||
5. **Migrate `CLLocationManager` delegate to `CLLocationUpdate`** (M-1) -- `LocationService.swift`
|
||||
6. **Replace `DispatchQueue.asyncAfter`** (M-6) -- `GatewayConnectionController.swift`, `ShareViewController.swift`
|
||||
7. **Replace `objc_sync` with `OSAllocatedUnfairLock`** (M-7) -- `GatewayConnectionController.swift`
|
||||
8. **Replace Combine `Timer.publish` and `onReceive`** (M-8) -- `OnboardingWizardView.swift`, `VoiceWakeWordsSettingsView.swift`
|
||||
9. **Replace callback-based `NotificationCenter` observers** (M-5) -- `VoiceWakeManager.swift`
|
||||
|
||||
### Phase 4: Low (Opportunistic)
|
||||
10. **Replace `NSLock` with `OSAllocatedUnfairLock`** (L-3) -- `VoiceWakeManager.swift`
|
||||
11. **Modernize `DateFormatter` to `FormatStyle`** (L-4) -- `GatewayDiscoveryDebugLogView.swift`
|
||||
12. **Address `@unchecked Sendable` patterns** (L-1, L-2) -- `WatchConnectivityReceiver`, `ScreenRecordService`
|
||||
|
||||
---
|
||||
|
||||
## Files Not Requiring Changes
|
||||
|
||||
The following files were audited and found to use modern patterns appropriately:
|
||||
|
||||
- `apps/ios/Sources/RootView.swift`
|
||||
- `apps/ios/Sources/RootTabs.swift`
|
||||
- `apps/ios/Sources/RootCanvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+Canvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift`
|
||||
- `apps/ios/Sources/Chat/ChatSheet.swift`
|
||||
- `apps/ios/Sources/Chat/IOSGatewayChatTransport.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceTab.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceWakePreferences.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySettingsStore.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayHealthMonitor.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectConfig.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionIssue.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySetupCode.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/KeychainStore.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenTab.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenWebView.swift`
|
||||
- `apps/ios/Sources/Onboarding/GatewayOnboardingView.swift`
|
||||
- `apps/ios/Sources/Onboarding/OnboardingStateStore.swift`
|
||||
- `apps/ios/Sources/Status/StatusPill.swift`
|
||||
- `apps/ios/Sources/Status/StatusGlassCard.swift`
|
||||
- `apps/ios/Sources/Status/StatusActivityBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayStatusBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayActionsDialog.swift`
|
||||
- `apps/ios/Sources/Status/VoiceWakeToast.swift`
|
||||
- `apps/ios/Sources/Device/DeviceInfoHelper.swift`
|
||||
- `apps/ios/Sources/Device/DeviceStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NetworkStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NodeDisplayName.swift`
|
||||
- `apps/ios/Sources/Services/NodeServiceProtocols.swift`
|
||||
- `apps/ios/Sources/Services/WatchMessagingService.swift`
|
||||
- `apps/ios/Sources/Services/NotificationService.swift`
|
||||
- `apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift`
|
||||
- `apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift`
|
||||
- `apps/ios/Sources/SessionKey.swift`
|
||||
- `apps/ios/Sources/Calendar/CalendarService.swift`
|
||||
- `apps/ios/Sources/Contacts/ContactsService.swift`
|
||||
- `apps/ios/Sources/EventKit/EventKitAuthorization.swift`
|
||||
- `apps/ios/Sources/Location/SignificantLocationMonitor.swift`
|
||||
- `apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxStore.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxView.swift`
|
||||
- `apps/ios/WatchApp/` (asset catalog only)
|
||||
324
apps/ios/audit-architecture.md
Normal file
324
apps/ios/audit-architecture.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# iOS App Architecture, Code Quality & Test Coverage Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, 16,244 LOC) and `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
OpenClawApp (@main)
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
NodeAppModel GatewayConnectionController
|
||||
(@Observable) (@Observable)
|
||||
[God Object] [Discovery + Connect]
|
||||
| |
|
||||
+-----------+-----------+ GatewayDiscoveryModel
|
||||
| | | | | GatewaySettingsStore
|
||||
| | | | | GatewayHealthMonitor
|
||||
| | | | |
|
||||
v v v v v
|
||||
Screen Voice Camera Services Gateway Sessions
|
||||
Ctrl Wake Ctrl (proto) (node + operator)
|
||||
Talk
|
||||
Mode
|
||||
|
||||
UI Layer (SwiftUI):
|
||||
RootCanvas -> ScreenWebView + StatusPill + Overlays
|
||||
RootTabs -> ScreenTab, VoiceTab, SettingsTab
|
||||
Onboarding -> OnboardingWizardView, QRScannerView
|
||||
Chat -> ChatSheet (wraps OpenClawChatUI package)
|
||||
|
||||
Service Layer (protocols in NodeServiceProtocols.swift):
|
||||
CameraServicing, ScreenRecordingServicing, LocationServicing,
|
||||
DeviceStatusServicing, PhotosServicing, ContactsServicing,
|
||||
CalendarServicing, RemindersServicing, MotionServicing,
|
||||
WatchMessagingServicing
|
||||
|
||||
Routing: NodeCapabilityRouter (command -> handler dictionary)
|
||||
|
||||
Shared Packages: OpenClawKit, OpenClawChatUI, OpenClawProtocol, SwabbleKit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Findings by Severity
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### C1. NodeAppModel is a God Object (2,787 LOC)
|
||||
- **File:** `Sources/Model/NodeAppModel.swift`
|
||||
- **Lines:** 1-2787 (entire file)
|
||||
- **Description:** NodeAppModel concentrates ~17 distinct responsibilities in a single 2,787-line class:
|
||||
1. Gateway WebSocket lifecycle (two sessions: node + operator)
|
||||
2. Gateway reconnect state machine with exponential backoff
|
||||
3. Background task management (grace periods, leases, suppression)
|
||||
4. Deep link handling and agent prompt routing
|
||||
5. Voice wake coordination (suspend/resume around other audio)
|
||||
6. Talk mode coordination
|
||||
7. Camera HUD state management
|
||||
8. Screen recording state
|
||||
9. Canvas/A2UI invoke handling (present, hide, navigate, evalJS, snapshot, push, reset)
|
||||
10. Camera invoke handling (list, snap, clip)
|
||||
11. Location invoke handling
|
||||
12. Device/Photos/Contacts/Calendar/Reminders/Motion invoke handling
|
||||
13. Watch messaging and notification mirroring
|
||||
14. Push notification (APNs) token management
|
||||
15. Share extension relay configuration
|
||||
16. Branding/config refresh from gateway
|
||||
17. Session key management and agent selection
|
||||
- **Impact:** Extremely difficult to test in isolation, reason about, or modify safely. The file already has `// swiftlint:disable type_body_length file_length` which indicates a known but unaddressed problem.
|
||||
- **Recommendation:** Extract at least these into separate types:
|
||||
- `GatewayConnectionLoop` (reconnect state machine, background lease management)
|
||||
- `NodeInvokeDispatcher` (all `handleXxxInvoke` methods, currently ~600 LOC)
|
||||
- `VoiceAudioCoordinator` (voice wake + talk mode suspend/resume logic)
|
||||
- `BackgroundLifecycleManager` (grace periods, suppression, leases)
|
||||
- `DeepLinkHandler` (agent prompt, deep link parsing/routing)
|
||||
- `PushNotificationManager` (APNs token, notification authorization)
|
||||
|
||||
#### C2. TalkModeManager is a God Object (2,153 LOC)
|
||||
- **File:** `Sources/Voice/TalkModeManager.swift`
|
||||
- **Lines:** 1-2153 (entire file, saved to disk due to size)
|
||||
- **Description:** TalkModeManager contains speech recognition, audio playback, gateway communication, provider API key management, push-to-talk state machine, and TTS. The file header acknowledges this: "This file intentionally centralizes talk mode state + behavior. It's large, and splitting would force `private` -> `fileprivate` across many members."
|
||||
- **Impact:** The `private` -> `fileprivate` concern is valid but solvable with extensions in the same file or a dedicated module with `internal` access.
|
||||
- **Recommendation:** Extract `TalkAudioPlayer`, `TalkSpeechRecognitionEngine`, `TalkConfigLoader`, `TalkPTTStateMachine` into separate files.
|
||||
|
||||
---
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H1. GatewayConnectionController is oversized (1,058 LOC)
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift`
|
||||
- **Lines:** 1-1058
|
||||
- **Description:** Exceeds the 500 LOC guideline. Mixes discovery coordination, TLS fingerprint verification, Bonjour service resolution, loopback IP detection, URL building, capability/command/permission registration, and auto-connect logic.
|
||||
- **Recommendation:** Extract `GatewayTLSVerifier`, `LoopbackHostDetector` (static utility), and `GatewayCapabilityRegistrar` (caps/commands/permissions).
|
||||
|
||||
#### H2. SettingsTab is oversized (1,032 LOC)
|
||||
- **File:** `Sources/Settings/SettingsTab.swift`
|
||||
- **Lines:** 1-1032
|
||||
- **Description:** A single monolithic SwiftUI view with ~30 `@AppStorage` properties and multiple nested sections.
|
||||
- **Recommendation:** Extract section views: `GatewaySettingsSection`, `VoiceSettingsSection`, `DeviceSettingsSection`, `AdvancedSettingsSection`.
|
||||
|
||||
#### H3. OnboardingWizardView is oversized (884 LOC)
|
||||
- **File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
- **Lines:** 1-884
|
||||
- **Description:** Multi-step wizard with QR scanning, manual connection, photo picker, and pairing logic all in one view.
|
||||
- **Recommendation:** Extract per-step views: `OnboardingWelcomeStep`, `OnboardingConnectStep`, `OnboardingAuthStep`.
|
||||
|
||||
#### H4. Heavy UserDefaults coupling (no abstraction layer)
|
||||
- **Files:** `NodeAppModel.swift`, `GatewayConnectionController.swift`, `SettingsTab.swift`, `GatewaySettingsStore.swift`, `RootCanvas.swift`
|
||||
- **Description:** `UserDefaults.standard` is accessed directly throughout the codebase (~70+ direct reads/writes with raw string keys). There is no typed key registry or wrapper, so:
|
||||
- Key typos compile silently
|
||||
- Default values are duplicated (e.g., `"camera.enabled"` checked with fallback `true` in two places)
|
||||
- Testing requires the `withUserDefaults` helper which mutates the shared `UserDefaults.standard`
|
||||
- **Recommendation:** Create a `Settings` enum with typed keys (similar to `VoiceWakePreferences`) and use dependency injection for `UserDefaults`.
|
||||
|
||||
#### H5. Significant test coverage gaps for critical paths
|
||||
- **Description:** Several critical modules have zero test coverage. See the Test Coverage Gap Analysis table below.
|
||||
- **Impact:** Changes to gateway connection lifecycle, background task management, voice/talk coordination, and canvas interaction cannot be regression-tested.
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1. Inconsistent module boundary patterns
|
||||
- **Description:** Some modules use proper protocol-based DI (camera, screen recording, location, device status, photos, contacts, calendar, reminders, motion, watch messaging via `NodeServiceProtocols.swift`), while others use concrete types directly:
|
||||
- `VoiceWakeManager` and `TalkModeManager` are concrete, not protocol-backed
|
||||
- `GatewayHealthMonitor` is concrete (but has testable init with sleep injection)
|
||||
- `ScreenController` is concrete with no protocol
|
||||
- `NotificationCentering` protocol exists but is ad hoc (not in `NodeServiceProtocols.swift`)
|
||||
- **Recommendation:** Add protocols for `VoiceWakeServicing`, `TalkModeServicing`, `ScreenControlling` to enable test doubles.
|
||||
|
||||
#### M2. Closure-based wiring instead of protocol conformance
|
||||
- **Files:** `NodeAppModel.swift:178-216`, `ScreenController.swift:14-18`
|
||||
- **Description:** `ScreenController.onDeepLink` and `ScreenController.onA2UIAction` are closure properties rather than delegate protocols. Similarly, `VoiceWakeManager.configure(onCommand:)` uses a closure. This makes the dependency graph harder to trace.
|
||||
- **Recommendation:** Consider delegate protocols for clearer contracts, or at minimum document the callback contracts.
|
||||
|
||||
#### M3. OpenClawApp.swift mixes concerns (541 LOC)
|
||||
- **File:** `Sources/OpenClawApp.swift`
|
||||
- **Lines:** 1-541
|
||||
- **Description:** Contains three distinct concerns in one file:
|
||||
1. `OpenClawAppDelegate` (push notifications, background tasks)
|
||||
2. `WatchPromptNotificationBridge` (notification category management, 200+ LOC)
|
||||
3. `OpenClawApp` (SwiftUI app entry point)
|
||||
- **Recommendation:** Extract `WatchPromptNotificationBridge` to its own file.
|
||||
|
||||
#### M4. GatewayDiagnostics embedded in GatewaySettingsStore file
|
||||
- **File:** `Sources/Gateway/GatewaySettingsStore.swift:352-448`
|
||||
- **Description:** `GatewayDiagnostics` enum (file-based logging) is defined at the bottom of `GatewaySettingsStore.swift` with no relation to settings storage.
|
||||
- **Recommendation:** Move to its own file `Gateway/GatewayDiagnostics.swift`.
|
||||
|
||||
#### M5. Duplicate code patterns in invoke handlers
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:1213-1358`
|
||||
- **Description:** Every `handleXxxInvoke` method follows the same pattern: decode params -> call service -> encode payload -> return response. The 12 invoke handlers repeat this boilerplate with minor variations. The `default:` case error response is duplicated 9 times verbatim.
|
||||
- **Recommendation:** Create a generic `invokeServiceMethod<P: Decodable, R: Encodable>` helper that handles the decode-call-encode-response cycle.
|
||||
|
||||
#### M6. No formal error domain or error catalog
|
||||
- **Description:** Errors are constructed ad hoc using `NSError(domain:code:userInfo:)` with inconsistent domains ("Screen", "Gateway", "Camera", "NodeAppModel", "GatewayHealthMonitor", "VoiceWake") and magic number codes. Only `CameraController.CameraError` uses a proper Swift error enum.
|
||||
- **Recommendation:** Define a unified `OpenClawIOSError` enum with cases for each domain, or at minimum use consistent error domains and documented code ranges.
|
||||
|
||||
#### M7. Two gateway sessions managed in parallel without shared state machine
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:96-98`
|
||||
- **Description:** `nodeGateway` and `operatorGateway` are two independent `GatewayNodeSession` instances with separate reconnect loops. Their connected states (`gatewayConnected`, `operatorConnected`) are tracked independently, but the UI only shows one "gateway status". Disconnect/reconnect of one does not coordinate with the other.
|
||||
- **Recommendation:** Extract a `DualGatewaySessionManager` that manages both sessions' lifecycles as a coordinated unit.
|
||||
|
||||
---
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1. `RootView.swift` is a trivial wrapper (7 LOC)
|
||||
- **File:** `Sources/RootView.swift`
|
||||
- **Description:** Contains only `struct RootView: View { var body: some View { RootCanvas() } }`. This adds an unnecessary layer of indirection.
|
||||
- **Recommendation:** Remove and use `RootCanvas` directly, or document why the indirection exists.
|
||||
|
||||
#### L2. Access control could be tighter
|
||||
- **Description:** Many types use default `internal` access where `private` or `fileprivate` would be more appropriate. For example:
|
||||
- `NodeAppModel.gatewayStatusText`, `nodeStatusText`, `operatorStatusText` are `var` (settable) from outside
|
||||
- `GatewayDiscoveryModel.gateways` is `var` (not `private(set)`)
|
||||
- `VoiceWakeManager.isEnabled`, `isListening` are publicly settable
|
||||
- **Recommendation:** Prefer `private(set)` for observable properties that should only be modified internally.
|
||||
|
||||
#### L3. `#if DEBUG` test hooks pattern
|
||||
- **Files:** `GatewayConnectionController.swift:929-989`, `VoiceWakeManager.swift:477-483`, `NodeAppModel.swift` (via `_test_` prefixed methods)
|
||||
- **Description:** Test hooks are exposed via `#if DEBUG` extensions with `_test_` prefixes. While functional, this pollutes the type's API surface.
|
||||
- **Recommendation:** This is a reasonable pattern for host-app tests. Consider using `@_spi(Testing)` when available in Swift 6 for cleaner separation.
|
||||
|
||||
#### L4. Naming inconsistency: `ThrowingContinuationSupport`
|
||||
- **File:** `Sources/OpenClawApp.swift:459`
|
||||
- **Description:** References `ThrowingContinuationSupport.resumeVoid` which appears to be defined in OpenClawKit. The name is verbose; a simple extension on `CheckedContinuation` would be more idiomatic.
|
||||
|
||||
#### L5. `GatewayTLSFingerprintProbe` uses `objc_sync_enter/exit` instead of a lock
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
- **Description:** `objc_sync_enter(self)` / `objc_sync_exit(self)` is an Objective-C runtime synchronization primitive. Modern Swift code should use `NSLock`, `os_unfair_lock`, or `Mutex` (Swift 6).
|
||||
- **Recommendation:** Replace with `NSLock` or `Mutex` for consistency with other lock usage (e.g., `NotificationInvokeLatch` uses `NSLock`).
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis
|
||||
|
||||
| Source File | LOC | Test File | Test LOC | Coverage |
|
||||
|---|---|---|---|---|
|
||||
| `Model/NodeAppModel.swift` | 2787 | `NodeAppModelInvokeTests.swift` | 478 | **Partial** - invoke dispatch only; no tests for reconnect, background, deep links |
|
||||
| `Voice/TalkModeManager.swift` | 2153 | `TalkModeConfigParsingTests.swift` | 31 | **Minimal** - config parsing only; no PTT, speech, or playback tests |
|
||||
| `Gateway/GatewayConnectionController.swift` | 1058 | `GatewayConnectionControllerTests.swift` + `GatewayConnectionSecurityTests.swift` | 226 | **Partial** - security + basic flow; no TLS probe, Bonjour resolve, or autoconnect tests |
|
||||
| `Settings/SettingsTab.swift` | 1032 | `SwiftUIRenderSmokeTests.swift` (1 test) | ~8 | **Smoke only** - verifies view hierarchy builds |
|
||||
| `Onboarding/OnboardingWizardView.swift` | 884 | None | 0 | **None** |
|
||||
| `OpenClawApp.swift` | 541 | None | 0 | **None** - WatchPromptNotificationBridge untested |
|
||||
| `Voice/VoiceWakeManager.swift` | 483 | `VoiceWakeManagerStateTests.swift` + `VoiceWakeManagerExtractCommandTests.swift` | 144 | **Good** - state transitions + command extraction |
|
||||
| `Gateway/GatewaySettingsStore.swift` | 448 | `GatewaySettingsStoreTests.swift` | 197 | **Good** |
|
||||
| `RootCanvas.swift` | 429 | `SwiftUIRenderSmokeTests.swift` | ~8 | **Smoke only** |
|
||||
| `Onboarding/GatewayOnboardingView.swift` | 371 | None | 0 | **None** |
|
||||
| `Screen/ScreenRecordService.swift` | 350 | `ScreenRecordServiceTests.swift` | 32 | **Minimal** |
|
||||
| `Camera/CameraController.swift` | 339 | `CameraControllerClampTests.swift` + `CameraControllerErrorTests.swift` | 38 | **Minimal** - clamp/error only; no capture flow tests |
|
||||
| `Services/WatchMessagingService.swift` | 284 | None (mock in NodeAppModelInvokeTests) | 0 | **None** |
|
||||
| `Screen/ScreenController.swift` | 267 | `ScreenControllerTests.swift` | 87 | **Good** |
|
||||
| `Contacts/ContactsService.swift` | 210 | None | 0 | **None** |
|
||||
| `Screen/ScreenWebView.swift` | 193 | None | 0 | **None** |
|
||||
| `Gateway/GatewayDiscoveryModel.swift` | 181 | `GatewayDiscoveryModelTests.swift` | 22 | **Minimal** |
|
||||
| `Location/LocationService.swift` | 177 | None | 0 | **None** |
|
||||
| `Media/PhotoLibraryService.swift` | 164 | None | 0 | **None** |
|
||||
| `Chat/IOSGatewayChatTransport.swift` | 142 | `IOSGatewayChatTransportTests.swift` | 30 | **Minimal** |
|
||||
| `Calendar/CalendarService.swift` | 135 | None | 0 | **None** |
|
||||
| `Reminders/RemindersService.swift` | 133 | None | 0 | **None** |
|
||||
| `Motion/MotionService.swift` | 100 | None | 0 | **None** |
|
||||
| `Model/NodeAppModel+WatchNotifyNormalization.swift` | 103 | `VoiceWakeGatewaySyncTests.swift` (partial) | 22 | **Minimal** |
|
||||
| `Model/NodeAppModel+Canvas.swift` | 59 | None | 0 | **None** |
|
||||
| `Gateway/GatewayHealthMonitor.swift` | 85 | None | 0 | **None** |
|
||||
| `Gateway/KeychainStore.swift` | 48 | `KeychainStoreTests.swift` | 22 | **Minimal** |
|
||||
| `Onboarding/OnboardingStateStore.swift` | 52 | `OnboardingStateStoreTests.swift` | 57 | **Good** |
|
||||
| `Gateway/GatewayConnectionIssue.swift` | 71 | `GatewayConnectionIssueTests.swift` | 33 | **Good** |
|
||||
| `SessionKey.swift` | 23 | Tested via `NodeAppModelInvokeTests` | - | **Good** (indirectly) |
|
||||
| `Settings/SettingsNetworkingHelpers.swift` | 40 | `SettingsNetworkingHelpersTests.swift` | 50 | **Good** |
|
||||
| `Voice/VoiceWakePreferences.swift` | 44 | `VoiceWakePreferencesTests.swift` | 38 | **Good** |
|
||||
| `Device/NodeDisplayName.swift` | 48 | Tested via GatewayConnectionControllerTests | - | **Partial** |
|
||||
|
||||
### Coverage Summary
|
||||
- **63 source files**, **25 test files** (24 test + 1 helper)
|
||||
- **17 source modules with zero test coverage** (service implementations, onboarding views, several gateway files)
|
||||
- **Test LOC ratio:** 1,884 / 16,244 = **11.6%** (low for a production app)
|
||||
- **Test framework:** Swift Testing (`@Test`, `#expect`) -- modern and correct
|
||||
- **Test patterns:** Good use of mocks (MockWatchMessagingService), `withUserDefaults` helper for isolation, `_test_` hooks for internal access. SwiftUI render smoke tests validate view hierarchy construction.
|
||||
|
||||
### Critical Untested Paths
|
||||
1. **Gateway reconnect state machine** - the most complex logic in the app (background lease, pairing pause, backoff) has zero tests
|
||||
2. **Background lifecycle management** - grace periods, suppression, wake handling untested
|
||||
3. **Onboarding flow** - 1,255 LOC across 3 files with zero tests
|
||||
4. **Push notification handling** - APNs registration, silent push, background refresh untested
|
||||
5. **TalkModeManager** - 2,153 LOC with only 31 LOC of config parsing tests
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection Assessment
|
||||
|
||||
### Well-Injected (protocol-based, testable)
|
||||
All services in `NodeServiceProtocols.swift` are protocol-based with default production implementations:
|
||||
- `CameraServicing` -> `CameraController`
|
||||
- `ScreenRecordingServicing` -> `ScreenRecordService`
|
||||
- `LocationServicing` -> `LocationService`
|
||||
- `DeviceStatusServicing` -> `DeviceStatusService`
|
||||
- `PhotosServicing` -> `PhotoLibraryService`
|
||||
- `ContactsServicing` -> `ContactsService`
|
||||
- `CalendarServicing` -> `CalendarService`
|
||||
- `RemindersServicing` -> `RemindersService`
|
||||
- `MotionServicing` -> `MotionService`
|
||||
- `WatchMessagingServicing` -> `WatchMessagingService`
|
||||
- `NotificationCentering` -> `LiveNotificationCenter`
|
||||
|
||||
`NodeAppModel.init()` accepts all of these via parameters with defaults -- excellent DI pattern.
|
||||
|
||||
### Not Injected (hardcoded dependencies)
|
||||
- `GatewaySettingsStore` - static enum, not injectable. Tests must use real `UserDefaults`/Keychain.
|
||||
- `GatewayDiagnostics` - static enum with file I/O, not injectable.
|
||||
- `GatewayDiscoveryModel` - concrete class created inside `GatewayConnectionController.init`.
|
||||
- `GatewayHealthMonitor` - created internally by `NodeAppModel` (but has testable init).
|
||||
- `VoiceWakeManager` - created internally, injected into SwiftUI environment.
|
||||
- `TalkModeManager` - injected via `NodeAppModel.init` parameter (good).
|
||||
- `ScreenController` - injected via `NodeAppModel.init` parameter (good).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Patterns
|
||||
|
||||
### Observation Framework Usage
|
||||
The app uses Swift's `Observation` framework (`@Observable`) consistently:
|
||||
- `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `VoiceWakeManager`, `TalkModeManager`, `ScreenController` are all `@Observable`.
|
||||
- SwiftUI views access them via `@Environment(Type.self)`.
|
||||
- No legacy `ObservableObject` / `@StateObject` patterns found -- this is correct per CLAUDE.md guidance.
|
||||
|
||||
### Environment Propagation
|
||||
```
|
||||
OpenClawApp
|
||||
|-- @State NodeAppModel -> .environment(appModel)
|
||||
|-- @State GatewayConnectionController -> .environment(gatewayController)
|
||||
|-- appModel.voiceWake (VoiceWakeManager) -> .environment(appModel.voiceWake)
|
||||
```
|
||||
This is clean, though `voiceWake` being both a property of `NodeAppModel` AND injected separately into the environment creates a potential consistency issue if they ever diverge.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Strengths
|
||||
|
||||
1. **Strong protocol-based DI for services** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities, enabling easy mocking in tests.
|
||||
2. **Modern Swift 6 / Observation adoption** - No legacy `ObservableObject` patterns; strict concurrency enabled.
|
||||
3. **NodeCapabilityRouter** - Clean command-routing pattern that decouples command registration from handling.
|
||||
4. **Dual gateway session architecture** - Separating node (device capabilities) from operator (chat/config) connections is architecturally sound.
|
||||
5. **GatewayConnectConfig** - Single source of truth struct for connection parameters.
|
||||
6. **Consistent input validation** - Nearly every string input is trimmed and empty-checked.
|
||||
7. **Keychain-based credential storage** - Sensitive data (tokens, passwords) stored in Keychain, not UserDefaults.
|
||||
8. **`CameraController` uses actor isolation** - Correct concurrency pattern for hardware resource.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Refactoring Priority
|
||||
|
||||
1. **[CRITICAL]** Split `NodeAppModel` into 5-6 focused types (highest ROI for testability)
|
||||
2. **[CRITICAL]** Split `TalkModeManager` into 3-4 focused types
|
||||
3. **[HIGH]** Add tests for gateway reconnect state machine
|
||||
4. **[HIGH]** Add tests for background lifecycle management
|
||||
5. **[HIGH]** Extract `SettingsTab` into section views
|
||||
6. **[MEDIUM]** Create typed `UserDefaults` key registry
|
||||
7. **[MEDIUM]** Unify error handling with a proper error catalog
|
||||
8. **[MEDIUM]** Extract duplicate invoke handler boilerplate
|
||||
399
apps/ios/audit-concurrency.md
Normal file
399
apps/ios/audit-concurrency.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Swift 6 Concurrency Audit: OpenClaw iOS App
|
||||
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~15K LOC)
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** Concurrency Auditor Agent
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Files audited | 63 |
|
||||
| `@MainActor` classes | 8 |
|
||||
| `actor` types | 1 (`CameraController`) |
|
||||
| `@unchecked Sendable` types | 9 |
|
||||
| `@preconcurrency` imports | 2 (`UserNotifications`, `WatchConnectivity`) |
|
||||
| `@preconcurrency` conformances | 2 (`UNUserNotificationCenterDelegate`, `NetServiceDelegate`) |
|
||||
| `nonisolated(unsafe)` usages | 1 |
|
||||
| `NSLock` usages | 6 |
|
||||
| `DispatchQueue` usages | 7 |
|
||||
| `objc_sync_enter/exit` usages | 1 |
|
||||
| `CheckedContinuation` usages | ~25 |
|
||||
| `@Observable` (Observation framework) types | 6 |
|
||||
| `ObservableObject` types | 0 |
|
||||
|
||||
### Overall Assessment
|
||||
|
||||
The codebase is in **good shape for Swift 6 strict concurrency**. The major model types use `@MainActor` + `@Observable` (Observation framework), there are zero `ObservableObject` usages, and the actor model is applied consistently. There are no `@Sendable` annotations missing on closure parameters in any obvious way, and the use of `@unchecked Sendable` is confined to genuine low-level synchronization wrappers. However, there are several areas that warrant attention.
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `GatewayTLSFingerprintProbe` uses `objc_sync_enter` + `@unchecked Sendable` with unsynchronized `didFinish` read
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:992-1058`
|
||||
**Severity:** Critical (potential data race)
|
||||
|
||||
```swift
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private var didFinish = false // line 996
|
||||
private var session: URLSession? // line 997
|
||||
private var task: URLSessionWebSocketTask? // line 998
|
||||
...
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self) // line 1039
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `start()` method (line 1006) reads and writes `self.session` and `self.task` without any lock. The `DispatchQueue.global().asyncAfter` timeout on line 1016 calls `finish()` from a background queue while `start()` sets properties on the caller's thread. Additionally, `URLSession` delegate callbacks arrive on an arbitrary delegate queue (nil was passed for `delegateQueue`), which means `urlSession(_:didReceive:completionHandler:)` and `finish()` can race.
|
||||
|
||||
**Recommendation:** Replace `objc_sync_enter/exit` with `NSLock` or `OSAllocatedUnfairLock`. Ensure all mutable state (`didFinish`, `session`, `task`) is accessed under the lock. Better yet, convert to an `actor` since this is a short-lived async operation. Alternatively, use `OSAllocatedUnfairLock<State>` wrapping a struct.
|
||||
|
||||
---
|
||||
|
||||
### C-2: `PhotoCaptureDelegate` and `MovieFileDelegate` lack synchronization on `didResume`
|
||||
|
||||
**File:** `Camera/CameraController.swift:260-339`
|
||||
**Severity:** Critical (potential double continuation resume)
|
||||
|
||||
```swift
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let continuation: CheckedContinuation<Data, Error>
|
||||
private var didResume = false // NOT thread-safe
|
||||
|
||||
func photoOutput(...) {
|
||||
guard !self.didResume else { return } // line 273
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
func photoOutput(...didFinishCaptureFor...) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return } // line 303
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `AVCapturePhotoCaptureDelegate` callbacks can arrive on different queues. The `didResume` flag is a plain `Bool` with no synchronization. If `didFinishProcessingPhoto` and `didFinishCaptureFor` are called concurrently (possible under certain error conditions), both could read `didResume` as `false` and resume the continuation twice, which is a fatal crash in debug builds and undefined behavior in release.
|
||||
|
||||
**Recommendation:** Protect `didResume` with `OSAllocatedUnfairLock<Bool>` or `NSLock`. The same issue applies to `MovieFileDelegate` on line 309.
|
||||
|
||||
---
|
||||
|
||||
### C-3: `GatewayDiagnostics.logWritesSinceCheck` is `nonisolated(unsafe)` static var
|
||||
|
||||
**File:** `Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** Critical (data race)
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
**Issue:** This counter is read and written inside `queue.async {}` blocks on `GatewayDiagnostics.queue`, but `nonisolated(unsafe)` tells the compiler to skip checking. The access is actually serialized by the private `DispatchQueue`, so it is functionally safe -- however, `nonisolated(unsafe)` is a red flag for Swift 6 audits because it permanently suppresses the compiler's data-race safety checks.
|
||||
|
||||
**Recommendation:** Replace with proper synchronization visible to the compiler. Either:
|
||||
1. Make it a local variable inside the `DispatchQueue` closure scope, or
|
||||
2. Wrap in `OSAllocatedUnfairLock<Int>` or a dedicated `actor`, or
|
||||
3. Since all accesses are on `GatewayDiagnostics.queue`, convert to a `@Sendable`-safe pattern that doesn't require `nonisolated(unsafe)`.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: `ScreenRecordService` is `@unchecked Sendable` but holds no state -- its inner `CaptureState` synchronizes via NSLock but `UncheckedSendableBox` silences Sendable checks
|
||||
|
||||
**File:** `Screen/ScreenRecordService.swift:4-11`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class ScreenRecordService: @unchecked Sendable {
|
||||
private struct UncheckedSendableBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UncheckedSendableBox` wraps **any** `T` (including non-Sendable types like `CMSampleBuffer`) and marks it `@unchecked Sendable`. This is used to pass `CMSampleBuffer` across threads in the capture handler. While `CMSampleBuffer` is effectively thread-safe for read-only access, this pattern silences the compiler completely and could mask future issues if the box is used for other types.
|
||||
|
||||
**Recommendation:** Use `nonisolated(unsafe) let value: T` instead if on Swift 6.2+, or document the specific thread-safety invariant. Consider constraining `T: Sendable` on the generic and handling `CMSampleBuffer` separately with a targeted unsafe annotation.
|
||||
|
||||
### H-2: `WatchMessagingService` is `@unchecked Sendable` with mutable `replyHandler` protected only by NSLock
|
||||
|
||||
**File:** `Services/WatchMessagingService.swift:23-28`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
```
|
||||
|
||||
**Issue:** While the `replyHandler` is properly protected by `NSLock`, the `session` property (`WCSession?`) is accessed from both the main thread (via delegate callbacks forwarded with `@preconcurrency`) and potentially from WatchConnectivity's internal threads. The `WCSession` properties like `isPaired`, `isWatchAppInstalled`, `isReachable` are read in `status(for:)` without synchronization and could race with delegate callbacks.
|
||||
|
||||
**Recommendation:** Convert to an `actor` or ensure all `WCSession` property reads happen on a specific isolation context. The lock properly protects `replyHandler`, so this is a moderate risk.
|
||||
|
||||
### H-3: `LocationService` stores `CheckedContinuation` as instance vars without synchronization between `nonisolated` delegate callbacks and `@MainActor` methods
|
||||
|
||||
**File:** `Location/LocationService.swift:13-14, 136-176`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
```
|
||||
|
||||
The delegate methods are marked `nonisolated`:
|
||||
```swift
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `nonisolated` delegate methods create `Task { @MainActor in }` to hop back to the main actor before accessing continuations. This is the correct pattern. However, there is a subtle race: if two delegate callbacks arrive in rapid succession, both could queue `@MainActor` tasks, and the second one would find the continuation already `nil`. This is handled (the `if let` guards), but the pattern is fragile. More importantly, `CLLocationManager` requires its delegate methods to be called on the queue it was created on. Since the class is `@MainActor`, the manager is created on main, and iOS should deliver delegate callbacks on main -- making the `nonisolated` annotation somewhat misleading.
|
||||
|
||||
**Recommendation:** Since `CLLocationManager` delivers callbacks on the thread/queue of the delegate's assigned queue (main in this case), the `nonisolated` annotation is technically unnecessary and may confuse future maintainers. Consider removing `nonisolated` and letting `@MainActor` inheritance apply. This would also let the compiler verify the continuation access is safe.
|
||||
|
||||
### H-4: `LiveNotificationCenter` is `@unchecked Sendable` wrapping a non-Sendable `UNUserNotificationCenter`
|
||||
|
||||
**File:** `Services/NotificationService.swift:18-58`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` is not `Sendable`. Wrapping it in `@unchecked Sendable` silences the compiler. In practice, `UNUserNotificationCenter.current()` returns a singleton that is thread-safe, so this is functionally fine -- but the compiler cannot verify this.
|
||||
|
||||
**Recommendation:** This is acceptable given `UNUserNotificationCenter.current()` is a thread-safe singleton. Document the invariant with a comment explaining why `@unchecked Sendable` is safe here. Alternatively, access the center via `UNUserNotificationCenter.current()` each time instead of storing it.
|
||||
|
||||
### H-5: `NetworkStatusService` is `@unchecked Sendable` but has no mutable state
|
||||
|
||||
**File:** `Device/NetworkStatusService.swift:5`
|
||||
**Severity:** High (misleading annotation)
|
||||
|
||||
```swift
|
||||
final class NetworkStatusService: @unchecked Sendable {
|
||||
```
|
||||
|
||||
**Issue:** `NetworkStatusService` has no stored properties at all. It creates `NWPathMonitor` locally in each method call. The `@unchecked Sendable` is unnecessary because a stateless final class is inherently `Sendable`.
|
||||
|
||||
**Recommendation:** Remove `@unchecked` -- just conform to `Sendable` directly. The class has no mutable state and is `final`, so it qualifies for automatic Sendable conformance.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `TalkModeManager` `pttCompletion` continuation stored as instance var could leak
|
||||
|
||||
**File:** `Voice/TalkModeManager.swift:43`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private var pttCompletion: CheckedContinuation<OpenClawTalkPTTStopPayload, Never>?
|
||||
```
|
||||
|
||||
**Issue:** If `pttCompletion` is set but the manager is deinitialized or the PTT session is interrupted without resuming it, the continuation will leak. `CheckedContinuation` logs a warning in debug builds when it is never resumed, and in production the caller will hang indefinitely.
|
||||
|
||||
**Recommendation:** Add a `deinit` or cleanup path that resumes `pttCompletion` with a default/error value. Also verify that all code paths that set `pttCompletion` eventually resume it (including error paths, cancellation, and mode changes).
|
||||
|
||||
### M-2: Heavy use of `Task { @MainActor in }` hops in code that is already `@MainActor`
|
||||
|
||||
**Files:** Multiple (OpenClawApp.swift:30-47, NodeAppModel.swift:179-207, etc.)
|
||||
**Severity:** Medium (performance/clarity)
|
||||
|
||||
```swift
|
||||
// In OpenClawAppDelegate which is already @MainActor:
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** When code is already on `@MainActor`, creating `Task { @MainActor in }` is redundant in terms of isolation but does defer execution to the next event loop tick. If the intent is immediate execution, this is a performance anti-pattern. If the intent is deferral, it should be documented.
|
||||
|
||||
**Recommendation:** Where immediate execution is intended, call the method directly. Where deferral is intentional, add a comment explaining why. In Swift 6.2 with `nonisolated(nonsending)` defaults, these patterns will behave differently.
|
||||
|
||||
### M-3: `GatewayDiscoveryModel` browser callbacks use closures that capture `self` without explicit `@Sendable`
|
||||
|
||||
**File:** `Gateway/GatewayDiscoveryModel.swift:60-96`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
...
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state // MainActor state access
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
guard let self else { return }
|
||||
self.gatewaysByDomain[domain] = results.compactMap { ... }
|
||||
```
|
||||
|
||||
**Issue:** These closures capture `self` (a `@MainActor` `@Observable` class) and mutate its state. If `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches these callbacks on a background queue (which NWBrowser does by default), this would be a main-actor isolation violation. The callbacks access `@MainActor`-isolated properties without explicitly hopping to the main actor.
|
||||
|
||||
**Recommendation:** Verify that `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches callbacks on the main queue. If not, wrap the callback bodies in `Task { @MainActor in ... }` or `await MainActor.run { ... }`. This is a potential data race if callbacks arrive off-main.
|
||||
|
||||
### M-4: `withObservationTracking` + `onChange` pattern in `GatewayConnectionController.observeDiscovery()` could miss updates
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private func observeDiscovery() {
|
||||
withObservationTracking {
|
||||
_ = self.discovery.gateways
|
||||
_ = self.discovery.statusText
|
||||
_ = self.discovery.debugLog
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery() // re-register
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `onChange` handler in `withObservationTracking` fires at most once per registration. The recursive re-registration inside `Task { @MainActor in }` means there is a window between when the `onChange` fires and when the new tracking is registered where changes could be missed. In practice, the `Task` hop is fast, but under heavy load or if the main actor queue is busy, rapid changes to `discovery.gateways` could be dropped.
|
||||
|
||||
**Recommendation:** This is a known limitation of `withObservationTracking` outside SwiftUI. Consider using `AsyncStream` or `Combine` publisher from the discovery model instead, which provides continuous observation without re-registration gaps.
|
||||
|
||||
### M-5: `GatewayServiceResolver` does not protect `didFinish` flag with a lock
|
||||
|
||||
**File:** `Gateway/GatewayServiceResolver.swift:9, 41-47`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private var didFinish = false
|
||||
|
||||
private func finish(result: ...) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NetServiceDelegate` callbacks can theoretically arrive on multiple threads (depending on how the service is scheduled). The `didFinish` flag is not synchronized. If `netServiceDidResolveAddress` and `netService(_:didNotResolve:)` are called concurrently, `finish` could be called twice.
|
||||
|
||||
**Recommendation:** Add `NSLock` protection or use `OSAllocatedUnfairLock<Bool>` for `didFinish`. Alternatively, ensure the service is always scheduled on the main run loop (which `BonjourServiceResolverSupport.start` may already do).
|
||||
|
||||
### M-6: `ContactsService`, `CalendarService`, `RemindersService`, `MotionService`, `PhotoLibraryService` conform to `Sendable` protocols but are plain classes without actor isolation
|
||||
|
||||
**Files:** Various service files
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class ContactsService: ContactsServicing { ... }
|
||||
// ContactsServicing: Sendable
|
||||
```
|
||||
|
||||
**Issue:** These classes have no mutable stored properties and are `final`, which technically makes them safe to mark `Sendable`. However, they don't explicitly declare `Sendable` conformance -- they inherit it through their protocol conformances (`ContactsServicing: Sendable`). The Swift 6 compiler will flag this because a `final class` without explicit `Sendable` or `@unchecked Sendable` conformance cannot implicitly satisfy `Sendable` requirements from protocols unless it is provably safe (no mutable state).
|
||||
|
||||
**Recommendation:** Since these classes are stateless and `final`, add explicit `: Sendable` conformance or verify they compile cleanly under strict concurrency.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@preconcurrency import UserNotifications` and `@preconcurrency import WatchConnectivity` suppress Sendable warnings
|
||||
|
||||
**Files:** `OpenClawApp.swift:7`, `Services/WatchMessagingService.swift:4`
|
||||
**Severity:** Low
|
||||
|
||||
**Issue:** `@preconcurrency` imports suppress sendability diagnostics for types from those modules. As Apple updates these frameworks for Sendable conformance in newer SDKs, the `@preconcurrency` should be removed to benefit from the compiler's checks.
|
||||
|
||||
**Recommendation:** Periodically check if these frameworks have been updated with Sendable annotations in newer Xcode versions and remove `@preconcurrency` when possible.
|
||||
|
||||
### L-2: `VoiceWakeManager.makeRecognitionResultHandler()` returns `@Sendable` closure that captures `[weak self]` correctly
|
||||
|
||||
**File:** `Voice/VoiceWakeManager.swift:301-313`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
The recognition result handler correctly captures `[weak self]` and hops to `@MainActor` before accessing any state. This is a good pattern.
|
||||
|
||||
### L-3: `CameraController` is an `actor` -- exemplary usage
|
||||
|
||||
**File:** `Camera/CameraController.swift:5`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
`CameraController` is the only `actor` in the codebase. It properly uses `nonisolated static` for pure functions and `async` for all state-mutating operations. This is a model for how other services could be structured.
|
||||
|
||||
### L-4: Several `Task { }` in `@MainActor` context don't explicitly annotate `@MainActor`
|
||||
|
||||
**Files:** Multiple
|
||||
**Severity:** Low
|
||||
|
||||
```swift
|
||||
// Inside @MainActor class:
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** In Swift 6.0, an unstructured `Task { }` created from `@MainActor` context inherits the actor context. However, in Swift 6.2 with `nonisolated(nonsending)` defaults, this behavior may change. Explicitly annotating `Task { @MainActor in }` makes the intent clear and forward-compatible.
|
||||
|
||||
**Recommendation:** Add explicit `@MainActor` annotation to `Task { }` blocks in `@MainActor` types where main-actor isolation is required.
|
||||
|
||||
### L-5: Consider migrating `NSLock` to `OSAllocatedUnfairLock` for better performance
|
||||
|
||||
**Files:** Multiple (6 usages)
|
||||
**Severity:** Low
|
||||
|
||||
`OSAllocatedUnfairLock` (available since iOS 16) is faster than `NSLock` for short critical sections. The existing `NSLock` usages in `AudioBufferQueue`, `NotificationInvokeLatch`, `CaptureState`, etc. are all protecting brief property accesses and would benefit from the switch.
|
||||
|
||||
**Recommendation:** Migrate `NSLock` to `OSAllocatedUnfairLock` where deployment target allows (iOS 16+). `TCPProbe.swift` already uses `OSAllocatedUnfairLock` -- apply the same pattern to other files.
|
||||
|
||||
### L-6: `NodeAppModel` is very large (~1500+ lines) which makes concurrency reasoning difficult
|
||||
|
||||
**File:** `Model/NodeAppModel.swift`
|
||||
**Severity:** Low (maintainability)
|
||||
|
||||
**Issue:** The large file size with many Task/async operations, multiple gateway sessions, and deeply nested closures makes it harder to reason about concurrency invariants. All state is `@MainActor` which is safe, but the complexity makes it harder to verify no accidental non-isolated access exists.
|
||||
|
||||
**Recommendation:** Consider splitting into smaller focused files (already noted with `NodeAppModel+Canvas.swift` and `NodeAppModel+WatchNotifyNormalization.swift` extensions). Further decomposition would improve auditability.
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Found
|
||||
|
||||
1. **Consistent `@MainActor` + `@Observable` usage**: All major model types (`NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `TalkModeManager`, `VoiceWakeManager`, `ScreenController`) use the Observation framework with `@MainActor` isolation. Zero `ObservableObject` usages.
|
||||
|
||||
2. **Zero `@Sendable` protocol conformance issues**: All service protocols (`CameraServicing`, `LocationServicing`, `DeviceStatusServicing`, etc.) correctly require `Sendable`.
|
||||
|
||||
3. **`CameraController` as `actor`**: Properly models concurrent camera access.
|
||||
|
||||
4. **`@Sendable` closures in callback APIs**: Callback closures (e.g., `onCommand` in `VoiceWakeManager`, `replyHandler` in `WatchMessagingService`) are properly annotated `@Sendable`.
|
||||
|
||||
5. **`CheckedContinuation` usage**: All continuation usages properly handle the single-resume invariant with `didResume`/`finished` flags (though some lack synchronization -- see C-2 and M-5).
|
||||
|
||||
6. **No `DispatchQueue.main.async` for UI updates**: All UI-related state mutations go through `@MainActor` or `Task { @MainActor in }`, not legacy GCD patterns.
|
||||
|
||||
7. **`ThrowingContinuationSupport.resumeVoid`**: Custom helper for void continuations reduces boilerplate and potential mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6.2 / iOS 26 Forward-Compatibility Notes
|
||||
|
||||
1. **`nonisolated(nonsending)` default**: Several `nonisolated` functions and closures may need `@concurrent` annotation if they are intended to run off the caller's actor. Review all `nonisolated` methods.
|
||||
|
||||
2. **Default `@MainActor` isolation**: If the project opts into Swift 6.2's `MainActorByDefault`, most explicit `@MainActor` annotations become redundant. The current architecture is well-positioned for this.
|
||||
|
||||
3. **`@preconcurrency` removal**: As Apple frameworks adopt Sendable, remove `@preconcurrency` imports for `UserNotifications` and `WatchConnectivity`.
|
||||
|
||||
4. **`sending` parameter keyword**: New `sending` keyword in Swift 6.2 may replace some `@Sendable` closure annotations for parameters that are consumed (not stored).
|
||||
376
apps/ios/audit-security.md
Normal file
376
apps/ios/audit-security.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# iOS App Security, Networking & Performance Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/`, `apps/shared/OpenClawKit/Sources/` (security-relevant shared code), `apps/ios/project.yml`, entitlements
|
||||
**Auditor:** Security & Performance Audit Agent
|
||||
|
||||
---
|
||||
|
||||
## 1. Security Posture Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a **generally strong security posture** for a local-network gateway client. Key strengths include:
|
||||
|
||||
- **Keychain usage for credentials:** Gateway tokens, passwords, instance IDs, and API keys are stored in Keychain (not UserDefaults).
|
||||
- **TLS certificate pinning:** SHA-256 certificate fingerprint pinning is implemented for gateway WebSocket connections via `GatewayTLSPinningSession`.
|
||||
- **Trust-on-first-use (TOFU) with user confirmation:** New gateway TLS fingerprints require explicit user approval before trust is established.
|
||||
- **Deep link confirmation:** Agent deep links (the `openclaw://` URL scheme) require user confirmation before execution, with message length limits.
|
||||
- **Web view security:** The canvas WKWebView uses `.nonPersistent()` data store and validates that A2UI action messages originate only from trusted/local-network URLs.
|
||||
- **Input sanitization:** Consistent `.trimmingCharacters(in: .whitespacesAndNewlines)` throughout, input length limits on contacts/calendar/photos queries.
|
||||
- **Permission gating:** All hardware capabilities (camera, location, microphone, contacts, calendar, photos) check authorization status before access.
|
||||
- **No hardcoded secrets:** No API keys, tokens, or credentials are hardcoded in the source.
|
||||
- **Swift 6 strict concurrency:** Enabled project-wide (`SWIFT_STRICT_CONCURRENCY: complete`), reducing data race risks.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Severity Findings
|
||||
|
||||
*No critical vulnerabilities identified.*
|
||||
|
||||
The app does not store plaintext passwords in UserDefaults, does not embed secrets, does not disable ATS globally, and does not allow arbitrary code execution from untrusted sources. The attack surface is primarily local-network, which limits remote exploitation vectors.
|
||||
|
||||
---
|
||||
|
||||
## 3. High Severity Findings
|
||||
|
||||
### H-1: TLS Fingerprints Stored in UserDefaults Instead of Keychain
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:19-38`
|
||||
**Severity:** HIGH
|
||||
|
||||
`GatewayTLSStore` stores TLS certificate fingerprints in `UserDefaults(suiteName: "ai.openclaw.shared")`. While fingerprints themselves are not secrets, they serve as the trust anchor for the TLS pinning system. An attacker with access to the device backup (unencrypted iTunes/Finder backup) or a compromised app extension sharing the same suite could modify these fingerprints and redirect gateway connections to a malicious server.
|
||||
|
||||
**Exploit scenario:** An attacker with physical or backup access modifies the stored fingerprint for a known gateway stableID, then performs a MITM attack on the LAN. The app connects using the attacker's fingerprint as the expected pin.
|
||||
|
||||
**Recommended fix:** Store TLS fingerprints in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (matching the existing `KeychainStore` pattern). This prevents backup extraction and cross-device compromise.
|
||||
|
||||
---
|
||||
|
||||
### H-2: KeychainStore Update Path Does Not Set Accessibility Level
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/KeychainStore.swift:20-37`
|
||||
**Severity:** HIGH
|
||||
|
||||
In `saveString()`, when the item already exists (`SecItemUpdate` succeeds), the update does not set or enforce the `kSecAttrAccessible` attribute. Only new items (via `SecItemAdd`) get `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. If a Keychain item was originally created with a less restrictive accessibility level (e.g., during a migration or by an older version), it retains that weaker level after updates.
|
||||
|
||||
**Exploit scenario:** An older app version or a migration path creates a Keychain item without specifying `kSecAttrAccessible` (defaults to `kSecAttrAccessibleWhenUnlocked`). After upgrading, the item retains the old accessibility level, potentially making it accessible via iCloud Keychain sync.
|
||||
|
||||
**Recommended fix:** Before `SecItemUpdate`, delete and re-add the item with the correct accessibility attribute, or explicitly include `kSecAttrAccessible` in the update query attributes. Example:
|
||||
|
||||
```swift
|
||||
// Delete-then-add pattern for consistent accessibility
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-3: Gateway Connection Metadata in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:170-217`
|
||||
**Severity:** HIGH
|
||||
|
||||
Last-known gateway connection details (host, port, TLS flag, stableID, connection kind) are stored in `UserDefaults.standard`. This data reveals which gateway servers the user connects to, their network topology, and connection preferences. UserDefaults are included in unencrypted device backups and can be read by MDM profiles or forensic tools.
|
||||
|
||||
**Affected keys:** `gateway.last.kind`, `gateway.last.host`, `gateway.last.port`, `gateway.last.tls`, `gateway.last.stableID`, `gateway.manual.host`, `gateway.manual.port`, `gateway.manual.tls`, `gateway.manual.clientId`, `gateway.clientIdOverride.*`, `gateway.selectedAgentId.*`.
|
||||
|
||||
**Recommended fix:** Move gateway connection metadata that reveals network topology to Keychain or use `NSFileProtectionCompleteUntilFirstUserAuthentication` on a dedicated plist file in the app's data directory.
|
||||
|
||||
---
|
||||
|
||||
## 4. Medium Severity Findings
|
||||
|
||||
### M-1: `NSAllowsArbitraryLoadsInWebContent` Enabled
|
||||
|
||||
**File:** `apps/ios/project.yml:110`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```yaml
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
```
|
||||
|
||||
This disables ATS protections for WKWebView content. While necessary for the canvas to load user-specified URLs from the gateway (including local-network HTTP servers), it means the web view can load insecure HTTP resources. The `ScreenController.navigate()` method does filter out loopback URLs but does not enforce HTTPS for remote URLs.
|
||||
|
||||
**Exploit scenario:** A gateway instructs the canvas to load an HTTP URL on a public network. The content is intercepted/modified via MITM.
|
||||
|
||||
**Recommended fix:** This is largely an accepted risk given the product's design (canvas loads gateway-specified URLs). Consider adding a user-visible indicator when the canvas is loading non-HTTPS content, and log a warning.
|
||||
|
||||
---
|
||||
|
||||
### M-2: Diagnostic Log File Written to Documents Directory Without Protection
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:359-448`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`GatewayDiagnostics` writes logs to `Documents/openclaw-gateway.log`. The Documents directory is accessible via iTunes file sharing (if enabled) and is included in device backups. Log entries include timestamps and gateway connection events which could reveal usage patterns.
|
||||
|
||||
Logs are written with `privacy: .public` in the `os.Logger` calls, meaning they are also visible in `Console.app` sysdiagnose captures without redaction.
|
||||
|
||||
**Recommended fix:** Write diagnostic logs to `Library/Caches/` instead (excluded from backups), apply `NSFileProtectionCompleteUntilFirstUserAuthentication`, and consider using `privacy: .private` or `privacy: .auto` for log messages that may contain sensitive connection details.
|
||||
|
||||
---
|
||||
|
||||
### M-3: Environment Variable Fallback for ElevenLabs API Key
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/TalkModeManager.swift:991-992`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```swift
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
```
|
||||
|
||||
The talk mode manager reads API keys from environment variables as a fallback. While environment variables are not accessible to other apps on iOS, they persist in process memory and could be captured in crash reports. This pattern is more suitable for development/debugging and should not ship in production builds.
|
||||
|
||||
**Recommended fix:** Gate this fallback behind `#if DEBUG` to prevent production builds from reading API keys from environment variables.
|
||||
|
||||
---
|
||||
|
||||
### M-4: Instance ID Dual-Storage Creates Sync Risk
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:291-312`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`ensureStableInstanceID()` maintains the instance ID in both Keychain and UserDefaults (`node.instanceId`). If either store is cleared independently (e.g., Keychain reset during device restore without backup, or UserDefaults cleared by storage pressure), the sync logic may create a new UUID, effectively orphaning the device's gateway registration.
|
||||
|
||||
While this is a robustness concern rather than a direct vulnerability, an attacker who can clear UserDefaults (e.g., via an MDM-deployed configuration profile) could force a device identity reset.
|
||||
|
||||
**Recommended fix:** Designate Keychain as the single source of truth and only mirror to UserDefaults for read convenience. Document the recovery flow for identity reset.
|
||||
|
||||
---
|
||||
|
||||
### M-5: No Rate Limiting on Deep Link Agent Prompts
|
||||
|
||||
**File:** `apps/ios/Sources/Model/NodeAppModel.swift:43-45, 92`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The `IOSDeepLinkAgentPolicy` defines `maxMessageChars = 20000` and `maxUnkeyedConfirmChars = 240`, and there is a `lastAgentDeepLinkPromptAt` timestamp. However, without a minimum interval check, a malicious webpage or app could rapidly fire `openclaw://` deep links, creating a flood of confirmation dialogs that degrade UX and potentially trick users into accepting a malicious prompt through fatigue.
|
||||
|
||||
**Recommended fix:** Enforce a minimum interval (e.g., 5 seconds) between successive deep link prompts, silently dropping duplicates. The `lastAgentDeepLinkPromptAt` field exists but its enforcement should be verified.
|
||||
|
||||
---
|
||||
|
||||
### M-6: QR Code Parsing Accepts Multiple Formats Without Strict Validation
|
||||
|
||||
**File:** `apps/ios/Sources/Onboarding/QRScannerView.swift:63-85`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The QR scanner tries two parsing strategies: `GatewayConnectDeepLink.fromSetupCode(payload)` (base64url JSON) and `DeepLinkParser.parse(url)` (URL format). The `GatewaySetupCode.decode()` method (`apps/ios/Sources/Gateway/GatewaySetupCode.swift`) accepts arbitrary base64-encoded JSON payloads that decode into `GatewaySetupPayload`. There is no signature verification or HMAC on the QR code content.
|
||||
|
||||
**Exploit scenario:** An attacker places a malicious QR code that encodes a gateway URL pointing to their controlled server. When scanned, the user is prompted to connect to the attacker's gateway.
|
||||
|
||||
**Mitigating factors:** The TLS trust prompt still fires for new gateways, requiring explicit user approval of the certificate fingerprint.
|
||||
|
||||
**Recommended fix:** Consider adding an HMAC or signing mechanism to QR setup codes so the app can verify they were generated by the user's own gateway. At minimum, clearly display the gateway URL/host to the user during the onboarding flow.
|
||||
|
||||
---
|
||||
|
||||
### M-7: WebSocket Maximum Message Size Set to 16 MB
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:55`
|
||||
**Severity:** MEDIUM (Performance/DoS)
|
||||
|
||||
```swift
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
```
|
||||
|
||||
A malicious or compromised gateway could send a 16 MB WebSocket message, causing a significant memory spike on the iOS device.
|
||||
|
||||
**Recommended fix:** Evaluate whether 16 MB is necessary. Consider progressive parsing or streaming for large payloads. Add a sanity check on incoming message size.
|
||||
|
||||
---
|
||||
|
||||
## 5. Low Severity Findings
|
||||
|
||||
### L-1: Location Data Sent Over Gateway WebSocket Without End-to-End Encryption
|
||||
|
||||
**File:** `apps/ios/Sources/Location/SignificantLocationMonitor.swift:21-38`
|
||||
**Severity:** LOW
|
||||
|
||||
Significant location updates (lat, lon, accuracy) are sent as JSON over the gateway WebSocket. While the WebSocket uses TLS (wss://), the gateway server itself can read the location data in plaintext. This is by design (the gateway processes location for hooks), but users should be informed that location data is accessible to the gateway process.
|
||||
|
||||
**Recommended fix:** Document this clearly in privacy documentation. Consider allowing users to configure location precision (rounding to neighborhood-level vs. exact coordinates).
|
||||
|
||||
---
|
||||
|
||||
### L-2: Camera Photo/Video Base64 Encoding in Memory
|
||||
|
||||
**Files:** `apps/ios/Sources/Camera/CameraController.swift:84`, `apps/ios/Sources/Media/PhotoLibraryService.swift:105`
|
||||
**Severity:** LOW
|
||||
|
||||
Camera captures and photo library images are base64-encoded in memory before being sent over the gateway. For large images (up to 1600px wide at 0.9 quality), this means the raw image data plus the base64 string (33% larger) coexist in memory briefly.
|
||||
|
||||
**Mitigating factors:** The app already clamps max width to 1600px and applies quality compression. Temporary files are cleaned up via `defer` blocks.
|
||||
|
||||
**Recommended fix:** Consider streaming base64 encoding or using a memory-mapped approach for very large payloads. Current implementation is adequate for the existing size limits.
|
||||
|
||||
---
|
||||
|
||||
### L-3: Screen Recording Output Path User-Controllable
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift:103-109`
|
||||
**Severity:** LOW
|
||||
|
||||
The `makeOutputURL` method accepts an optional `outPath` parameter. If this comes from a gateway command, a malicious gateway could specify a path outside the app's sandbox (which iOS would block) or overwrite files within the sandbox.
|
||||
|
||||
**Mitigating factors:** iOS sandbox prevents writing outside the app container. The `defer` cleanup in the caller should handle temporary files.
|
||||
|
||||
**Recommended fix:** Validate that `outPath` is within the app's temporary or documents directory before using it. Reject absolute paths that don't start with the app's known writable directories.
|
||||
|
||||
---
|
||||
|
||||
### L-4: Voice Wake Preferences Stored in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakePreferences.swift:23-29`
|
||||
**Severity:** LOW
|
||||
|
||||
Trigger words and voice wake enabled state are stored in `UserDefaults.standard`. While trigger words are not sensitive per se, they reveal user behavior patterns.
|
||||
|
||||
**Recommended fix:** Acceptable for non-sensitive preferences. No action needed unless trigger words become user-configurable sensitive phrases.
|
||||
|
||||
---
|
||||
|
||||
### L-5: `nonisolated(unsafe)` in GatewayDiagnostics
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** LOW
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
This counter is accessed from the `DispatchQueue` without the lock that protects the file I/O. While this is a benign data race (used only for approximate frequency gating), it could theoretically cause the log size check to be skipped or double-triggered.
|
||||
|
||||
**Recommended fix:** Move the counter into the `queue.async` block or use an atomic counter.
|
||||
|
||||
---
|
||||
|
||||
### L-6: `objc_sync_enter` Used for Synchronization
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayTLSFingerprintProbe.finish()` uses `objc_sync_enter/exit` for synchronization. This is the Objective-C `@synchronized` primitive. While functional, modern Swift best practice prefers `OSAllocatedUnfairLock` (as used correctly in `TCPProbe`), `NSLock`, or actor isolation.
|
||||
|
||||
**Recommended fix:** Replace with `OSAllocatedUnfairLock` for consistency with the rest of the codebase.
|
||||
|
||||
---
|
||||
|
||||
### L-7: No Certificate Revocation Checking
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:59-96`
|
||||
**Severity:** LOW
|
||||
|
||||
The TLS pinning implementation checks the certificate fingerprint but does not perform OCSP or CRL revocation checking. For self-signed certificates (typical in local gateway setups), this is expected. For publicly-signed certificates, revocation checking would add defense in depth.
|
||||
|
||||
**Recommended fix:** For the current use case (self-signed gateway certs on LAN), this is acceptable. If public CA certificates are used in future, consider enabling revocation checking via `SecTrustSetOptions`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Concerns
|
||||
|
||||
### P-1: ISO8601DateFormatter Created Per Log Entry
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:422-424`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayDiagnostics.log()` creates a new `ISO8601DateFormatter` for every log call. `ISO8601DateFormatter` is relatively expensive to initialize.
|
||||
|
||||
**Recommended fix:** Cache a static formatter instance (thread-safety is acceptable for `ISO8601DateFormatter` as it is immutable after configuration).
|
||||
|
||||
---
|
||||
|
||||
### P-2: Observation Tracking Re-registration Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** LOW
|
||||
|
||||
The `observeDiscovery()` method uses `withObservationTracking` and recursively calls itself in the `onChange` closure. This is the standard Swift Observation pattern, but each change creates a new `Task` and re-registers tracking. Under rapid discovery state changes, this could create a burst of Task allocations.
|
||||
|
||||
**Mitigating factors:** Discovery state changes are infrequent (Bonjour events).
|
||||
|
||||
**Recommended fix:** Consider debouncing or coalescing rapid state changes.
|
||||
|
||||
---
|
||||
|
||||
### P-3: Synchronous Photo Library Access
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift:69-71`
|
||||
**Severity:** MEDIUM (Performance)
|
||||
|
||||
```swift
|
||||
options.isSynchronous = true
|
||||
```
|
||||
|
||||
`PHImageManager.requestImage` is called synchronously, which blocks the calling thread until the image is loaded and decoded. For network-backed assets (iCloud Photo Library), this could block for seconds.
|
||||
|
||||
**Recommended fix:** Use asynchronous image loading with a continuation wrapper to avoid blocking.
|
||||
|
||||
---
|
||||
|
||||
### P-4: Camera Clip Base64 Encoding of Video Data
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift:89-140`
|
||||
**Severity:** LOW
|
||||
|
||||
Video clips (up to 60 seconds) are fully loaded into memory as `Data` and then base64-encoded. A 60-second medium-quality MP4 could be 10-30 MB, producing a 13-40 MB base64 string in memory.
|
||||
|
||||
**Mitigating factors:** Default duration is 3 seconds, keeping typical payloads small. The 60-second max is enforced at `CameraController.clampDurationMs`.
|
||||
|
||||
**Recommended fix:** Consider a streaming upload mechanism for clips longer than ~10 seconds.
|
||||
|
||||
---
|
||||
|
||||
## 7. OWASP Mobile Top 10 2024 Checklist
|
||||
|
||||
| # | OWASP Category | Status | Notes |
|
||||
|---|---------------|--------|-------|
|
||||
| M1 | Improper Credential Usage | **PASS** | Credentials stored in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. No hardcoded secrets. API keys from env vars gated to dev builds (recommended). |
|
||||
| M2 | Inadequate Supply Chain Security | **PASS** | Dependencies are version-pinned via Package.resolved. SwiftLint and SwiftFormat enforce code quality. |
|
||||
| M3 | Insecure Authentication/Authorization | **PASS** | Gateway authentication uses token + password stored in Keychain. TLS pinning prevents MITM. Deep links require user confirmation. |
|
||||
| M4 | Insufficient Input/Output Validation | **PASS** | Input trimming and length limits applied consistently. QR code parsing has two validated paths. Calendar/contacts sanitize inputs. |
|
||||
| M5 | Insecure Communication | **PASS with notes** | TLS required for non-loopback connections. Certificate pinning implemented. `NSAllowsArbitraryLoadsInWebContent` allows HTTP in web views (accepted risk for canvas). |
|
||||
| M6 | Inadequate Privacy Controls | **PASS with notes** | All sensitive permissions have usage descriptions. Location data sent to gateway in plaintext over TLS. Photo library access checks authorization. Logging uses `privacy: .public` for some potentially sensitive data. |
|
||||
| M7 | Insufficient Binary Protections | **N/A** | Standard Xcode compilation. No jailbreak detection implemented (acceptable for non-financial app). |
|
||||
| M8 | Security Misconfiguration | **PASS with notes** | TLS fingerprints in UserDefaults (H-1). Gateway metadata in UserDefaults (H-3). Entitlements minimal (only `aps-environment`). |
|
||||
| M9 | Insecure Data Storage | **PASS with notes** | Credentials in Keychain (good). Gateway connection metadata in UserDefaults (H-3). Diagnostic logs in Documents directory (M-2). |
|
||||
| M10 | Insufficient Cryptography | **PASS** | SHA-256 for certificate fingerprinting via CryptoKit. No custom/weak crypto implementations. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of Recommendations by Priority
|
||||
|
||||
### Immediate (High)
|
||||
1. **H-1:** Move TLS fingerprint storage from UserDefaults to Keychain
|
||||
2. **H-2:** Fix KeychainStore to enforce accessibility level on updates (delete + re-add)
|
||||
3. **H-3:** Move gateway connection metadata out of UserDefaults
|
||||
|
||||
### Short-term (Medium)
|
||||
4. **M-3:** Gate `ELEVENLABS_API_KEY` env var fallback behind `#if DEBUG`
|
||||
5. **M-2:** Move diagnostic logs to Caches directory, apply file protection
|
||||
6. **M-5:** Enforce minimum interval between deep link prompts
|
||||
7. **M-6:** Add HMAC/signature to QR setup codes
|
||||
8. **M-7:** Evaluate reducing WebSocket max message size from 16 MB
|
||||
9. **P-3:** Convert synchronous photo library loading to async
|
||||
|
||||
### Long-term (Low / Hardening)
|
||||
10. **L-6:** Replace `objc_sync_enter` with `OSAllocatedUnfairLock`
|
||||
11. **L-3:** Validate screen recording output paths
|
||||
12. **P-1:** Cache ISO8601DateFormatter instances
|
||||
13. **M-1:** Add indicator for non-HTTPS canvas content
|
||||
14. **L-5:** Fix `nonisolated(unsafe)` data race in log counter
|
||||
|
||||
---
|
||||
|
||||
## 9. Positive Security Patterns Worth Preserving
|
||||
|
||||
- **TOFU with explicit user confirmation** for TLS fingerprints is a pragmatic and user-friendly approach for self-signed certificates.
|
||||
- **Dual WebSocket sessions** (node + operator) with separate role scoping provides good privilege separation.
|
||||
- **`websiteDataStore = .nonPersistent()`** for the canvas WKWebView prevents session data leakage.
|
||||
- **Origin validation in `CanvasA2UIActionMessageHandler`** (checking `isTrustedCanvasUIURL` and `isLocalNetworkCanvasURL`) is a strong defense against arbitrary web content triggering actions.
|
||||
- **Loopback URL rejection** in `ScreenController.navigate()` prevents SSRF-like attacks from the gateway.
|
||||
- **Autoconnect only to previously trusted gateways** (stored TLS pin required) prevents connecting to rogue gateways after TOFU.
|
||||
- **Permission checks before hardware access** with clear error messages is well-implemented.
|
||||
- **`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`** is the correct Keychain accessibility level for this use case (device-local, available after first unlock for background operation).
|
||||
318
apps/ios/audit-uiux.md
Normal file
318
apps/ios/audit-uiux.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# iOS App UI/UX & Accessibility Audit
|
||||
|
||||
**Audit Date:** 2026-03-02
|
||||
**Scope:** All SwiftUI view files in `apps/ios/Sources/`
|
||||
**Reference Standards:** Apple HIG (iOS 26), WCAG 2.1 AA, Liquid Glass design language, SwiftUI accessibility best practices
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Health Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a well-structured SwiftUI codebase with several accessibility-conscious patterns already in place. The app uses the modern `@Observable` / `Observation` framework consistently, respects `accessibilityReduceMotion`, responds to `colorSchemeContrast`, and provides accessibility labels on key interactive elements. However, there are significant gaps in Dynamic Type support, localization readiness, haptic feedback, and iPad adaptivity that should be addressed before the next major release.
|
||||
|
||||
**Strengths:**
|
||||
- Good use of `@Environment(\.accessibilityReduceMotion)` in animation-heavy views (RootTabs, StatusPill)
|
||||
- `StatusGlassCard` correctly responds to `colorSchemeContrast` for increased visibility
|
||||
- `StatusPill` has proper `accessibilityLabel`, `accessibilityValue`, and `accessibilityHint`
|
||||
- `TalkOrbOverlay` uses `accessibilityElement(children: .combine)` to present a single VoiceOver element
|
||||
- Consistent use of `@Observable` macro (Observation framework) over legacy `ObservableObject`
|
||||
- Glass material effects on overlays (`.ultraThinMaterial`) with light/dark mode awareness
|
||||
|
||||
**Weaknesses:**
|
||||
- Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize` environment usage)
|
||||
- Zero localization infrastructure (no `NSLocalizedString`, `String(localized:)`, or `.strings` files)
|
||||
- Zero haptic feedback across the entire app
|
||||
- Several views lack accessibility labels entirely
|
||||
- Hardcoded dimensions in TalkOrbOverlay will break on small screens
|
||||
- SettingsTab is a monolithic ~650 LOC file
|
||||
- No iPad-specific layout adaptations
|
||||
- `RootCanvas` voiceWakeToast animation does not respect `reduceMotion` (unlike `RootTabs`)
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: RootCanvas animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:159-167`
|
||||
**Description:** The `voiceWakeToastText` animation in `RootCanvas` uses hardcoded `.spring()` and `.easeOut()` animations without checking `@Environment(\.accessibilityReduceMotion)`. The sibling `RootTabs` view correctly guards the same toast animation with `reduceMotion ? .none : .spring(...)`.
|
||||
|
||||
**Impact:** Users who require reduced motion will see unexpected animations in the canvas view.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// In RootCanvas, add the environment property:
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Then guard animations:
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
// ...
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
```
|
||||
|
||||
### C-2: TalkOrbOverlay perpetual animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:15-26`
|
||||
**Description:** The pulsing ring animations use `.repeatForever(autoreverses: false)` without checking `reduceMotion`. These are high-frequency, continuous animations that can cause discomfort for users with vestibular disorders.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Replace pulse animations with:
|
||||
if !reduceMotion {
|
||||
Circle()
|
||||
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||
}
|
||||
```
|
||||
|
||||
### C-3: CameraFlashOverlay has no accessibility announcement
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:405-429`
|
||||
**Description:** `CameraFlashOverlay` flashes the screen white at 85% opacity. VoiceOver users have no indication that a photo was taken. There is no `AccessibilityNotification.Announcement` posted, and the flash itself could trigger photosensitive reactions.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// Post an accessibility announcement:
|
||||
AccessibilityNotification.Announcement("Photo captured").post()
|
||||
|
||||
// Add prefers-reduced-motion check to skip or soften the flash:
|
||||
if reduceMotion {
|
||||
// Skip flash, or use subtle opacity change
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Zero Dynamic Type support across the entire app
|
||||
|
||||
**Files:** All view files in `Sources/`
|
||||
**Description:** No view uses `@ScaledMetric`, `@Environment(\.dynamicTypeSize)`, or `ContentSizeCategory`. All hardcoded font sizes and dimensions (e.g., `font(.system(size: 16))` in `OverlayButton`, `font(.system(size: 12))` in monospaced debug text, `frame(width: 320, height: 320)` in TalkOrbOverlay) will not scale with the user's preferred text size. Apple's HIG strongly recommends supporting Dynamic Type for all text.
|
||||
|
||||
**Key locations:**
|
||||
- `Sources/RootCanvas.swift:358` - OverlayButton uses fixed `size: 16`
|
||||
- `Sources/Voice/TalkOrbOverlay.swift:16,23,39` - Fixed 320pt and 190pt circles
|
||||
- `Sources/Status/StatusPill.swift:52` - Fixed `width: 9, height: 9` indicator dot
|
||||
- `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:24` - Fixed `font(.callout)`
|
||||
- `Sources/Gateway/GatewayOnboardingView.swift:345-346` - Fixed `.system(size: 12)` monospaced text
|
||||
|
||||
**Recommended Fix:** Use semantic font styles (`.body`, `.headline`, etc.) instead of fixed sizes where possible. For custom dimensions, use `@ScaledMetric`:
|
||||
```swift
|
||||
@ScaledMetric(relativeTo: .body) private var orbSize: CGFloat = 190
|
||||
@ScaledMetric(relativeTo: .caption) private var dotSize: CGFloat = 9
|
||||
```
|
||||
|
||||
### H-2: OnboardingWizardView missing accessibility labels on interactive elements
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
**Description:** Multiple interactive elements lack accessibility labels:
|
||||
- `OnboardingModeRow` (line 861-884): Radio-style selection buttons have no `accessibilityAddTraits(.isButton)` or clear selection state announcement. VoiceOver users cannot tell which mode is selected.
|
||||
- Gateway list connect buttons (line 453-465): `ProgressView` and "Resolving..." text lack accessibility context.
|
||||
- QR scanner action (line 319-326): "Scan QR Code" button label is good, but the status line below it (line 340-345) is not connected as an accessibility value.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// OnboardingModeRow:
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(self.selected ? [.isButton, .isSelected] : .isButton)
|
||||
.accessibilityLabel("\(self.title), \(self.subtitle)")
|
||||
.accessibilityValue(self.selected ? "Selected" : "Not selected")
|
||||
```
|
||||
|
||||
### H-3: No localization infrastructure
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** The entire app uses hardcoded English strings with no localization wrapping. No `NSLocalizedString`, `String(localized:)`, `.strings`/`.stringsdict` files, or `LocalizedStringKey` usage was found. This makes the app inaccessible to non-English speakers and violates Apple's HIG recommendation to support multiple languages.
|
||||
|
||||
**Key examples:**
|
||||
- `Sources/Settings/SettingsTab.swift`: All section headers, labels, help text
|
||||
- `Sources/Onboarding/OnboardingWizardView.swift`: "Welcome", "Connected", all step descriptions
|
||||
- `Sources/Status/StatusPill.swift`: "Connected", "Connecting...", "Error", "Offline"
|
||||
- `Sources/Voice/VoiceTab.swift`: All list labels
|
||||
|
||||
**Recommended Fix:** Wrap all user-facing strings in `String(localized:)` or use `LocalizedStringResource`. Create a `Localizable.xcstrings` catalog.
|
||||
|
||||
### H-4: No haptic feedback anywhere in the app
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** No `UIImpactFeedbackGenerator`, `UINotificationFeedbackGenerator`, `UISelectionFeedbackGenerator`, or `.sensoryFeedback()` modifier usage found. Key interaction points that would benefit from haptics:
|
||||
- Gateway connection success/failure
|
||||
- Voice wake trigger detection
|
||||
- Talk mode orb tap
|
||||
- QR code successfully scanned
|
||||
- Toggle state changes in Settings
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// iOS 17+ SwiftUI modifier:
|
||||
.sensoryFeedback(.success, trigger: appModel.gatewayServerName != nil)
|
||||
|
||||
// For Talk orb tap:
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: tapCount)
|
||||
```
|
||||
|
||||
### H-5: GatewayTrustPromptAlert uses deprecated `Alert` API
|
||||
|
||||
**File:** `Sources/Gateway/GatewayTrustPromptAlert.swift:17-35`
|
||||
**Description:** Uses the deprecated `Alert(title:message:primaryButton:secondaryButton:)` initializer pattern. This API was deprecated in iOS 15 in favor of the `alert(_:isPresented:actions:message:)` modifier. Same issue in `DeepLinkAgentPromptAlert.swift:15-33`.
|
||||
|
||||
**Recommended Fix:** Migrate to the modern `alert` modifier with `@ViewBuilder` actions.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: SettingsTab is a monolithic view (~650+ LOC)
|
||||
|
||||
**File:** `Sources/Settings/SettingsTab.swift`
|
||||
**Description:** SettingsTab contains the entire settings UI, including gateway connection, device features, advanced debug options, agent picker, and reset logic. The file has a `// swiftlint:disable type_body_length` comment acknowledging this. This makes the view hard to maintain and test.
|
||||
|
||||
**Recommended Fix:** Extract into focused sub-views:
|
||||
- `GatewaySettingsSection`
|
||||
- `DeviceFeaturesSection`
|
||||
- `AdvancedSettingsSection`
|
||||
- `DeviceInfoSection`
|
||||
|
||||
### M-2: No empty states for VoiceTab when disconnected
|
||||
|
||||
**File:** `Sources/Voice/VoiceTab.swift`
|
||||
**Description:** VoiceTab always shows the same status labels regardless of gateway connection state. When disconnected, it should show a clear empty state explaining that voice features require a gateway connection, with a CTA to connect.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
if appModel.gatewayServerName == nil {
|
||||
ContentUnavailableView(
|
||||
"Not Connected",
|
||||
systemImage: "antenna.radiowaves.left.and.right.slash",
|
||||
description: Text("Connect to a gateway to use voice features."))
|
||||
}
|
||||
```
|
||||
|
||||
### M-3: No loading/error states in GatewayQuickSetupSheet
|
||||
|
||||
**File:** `Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
**Description:** When `bestCandidate` is nil and no gateways are found, the sheet shows a text message but no visual indicator that discovery is actively running. No retry button or activity indicator is shown during the discovery phase.
|
||||
|
||||
### M-4: OverlayButton touch target may be too small
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:348-403`
|
||||
**Description:** `OverlayButton` uses `padding(10)` around a 16pt icon, resulting in a ~36pt touch target. Apple HIG recommends a minimum of 44pt x 44pt for touch targets.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
// or increase padding to at least 14pt
|
||||
```
|
||||
|
||||
### M-5: No keyboard shortcut support
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.keyboardShortcut()` modifiers found anywhere. iPad users with external keyboards have no keyboard navigation shortcuts for common actions like opening chat, settings, or toggling voice.
|
||||
|
||||
### M-6: TalkOrbOverlay hardcoded dimensions break on small screens
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:16,23,39`
|
||||
**Description:** The pulse rings are hardcoded at 320pt width/height, and the inner orb at 190pt. On iPhone SE (320pt logical width), the rings will extend beyond screen bounds. On iPad, the orb will appear relatively small.
|
||||
|
||||
**Recommended Fix:** Use `GeometryReader` or `@ScaledMetric` for adaptive sizing:
|
||||
```swift
|
||||
GeometryReader { proxy in
|
||||
let size = min(proxy.size.width, proxy.size.height) * 0.65
|
||||
Circle().frame(width: size, height: size)
|
||||
}
|
||||
```
|
||||
|
||||
### M-7: ScreenTab error overlay not accessible
|
||||
|
||||
**File:** `Sources/Screen/ScreenTab.swift:12-21`
|
||||
**Description:** The error text overlay appears only when `errorText` is set and the gateway is disconnected, but there is no VoiceOver announcement when the error appears or disappears. Screen reader users may not notice the error.
|
||||
|
||||
### M-8: No pull-to-refresh on any list view
|
||||
|
||||
**Files:** `Sources/Voice/VoiceTab.swift`, `Sources/Gateway/GatewayDiscoveryDebugLogView.swift`
|
||||
**Description:** List views do not support `.refreshable {}` for pull-to-refresh, which is a standard iOS interaction pattern.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: Inconsistent glass card styling between RootTabs and RootCanvas
|
||||
|
||||
**Files:** `Sources/RootTabs.swift`, `Sources/RootCanvas.swift`
|
||||
**Description:** `RootTabs` shows `StatusPill` without the `brighten` parameter (defaults to false), while `RootCanvas.CanvasContent` passes `brighten` based on color scheme. This can cause visual inconsistency if both code paths are reachable.
|
||||
|
||||
### L-2: VoiceWakeToast hardcoded top offset
|
||||
|
||||
**Files:** `Sources/RootTabs.swift:47`, `Sources/RootCanvas.swift:329`
|
||||
**Description:** `.safeAreaPadding(.top, 58)` is a magic number that assumes the StatusPill height. If the pill height changes (e.g., with Dynamic Type), the toast will overlap.
|
||||
|
||||
### L-3: No app-wide tint/accent color configuration
|
||||
|
||||
**Files:** `Sources/OpenClawApp.swift`
|
||||
**Description:** No `.tint()` or `accentColor` is set at the app level. The default blue accent is used for buttons and toggles, but the app uses `appModel.seamColor` for some elements. This creates visual inconsistency.
|
||||
|
||||
### L-4: ConnectionStatusBox uses hardcoded monospaced font size
|
||||
|
||||
**File:** `Sources/Onboarding/GatewayOnboardingView.swift:345-346`
|
||||
**Description:** `.font(.system(size: 12, weight: .regular, design: .monospaced))` will not scale with Dynamic Type.
|
||||
|
||||
### L-5: DateFormatter instances in GatewayDiscoveryDebugLogView are not locale-aware
|
||||
|
||||
**File:** `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:49-53`
|
||||
**Description:** `DateFormatter` with hardcoded `dateFormat = "HH:mm:ss"` does not respect the user's locale for time formatting. Should use `.dateStyle`/`.timeStyle` or `formatted()`.
|
||||
|
||||
### L-6: No transition animations on sheet presentations
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:92-111`
|
||||
**Description:** The `.sheet(item:)` presentations for settings, chat, and quick setup use default sheet transitions. Custom `presentationDetents` could improve the UX for smaller sheets like Quick Setup.
|
||||
|
||||
### L-7: Onboarding wizard duplicate padding
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift:344-346`
|
||||
**Description:** The welcome step has duplicate `.padding(.horizontal, 24)` on the status line (lines 344 and 345), which doubles the intended padding.
|
||||
|
||||
### L-8: No VoiceOver rotor actions
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.accessibilityAction(named:)` or custom rotor items are defined. Power VoiceOver users could benefit from custom actions for common operations.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Compliance Checklist
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|---|---|---|
|
||||
| VoiceOver labels on all interactive elements | Partial | Overlay buttons, StatusPill, ChatSheet close, SettingsTab close have labels. OnboardingModeRow, gateway list items, many settings toggles missing. |
|
||||
| VoiceOver hints for non-obvious actions | Partial | StatusPill has hint. Most buttons lack hints. |
|
||||
| VoiceOver value for stateful elements | Partial | StatusPill has value. Toggle states auto-announced by SwiftUI. OnboardingModeRow selection not announced. |
|
||||
| Dynamic Type support | Missing | No `@ScaledMetric`, no `dynamicTypeSize` environment, fixed font sizes throughout. |
|
||||
| Reduce Motion respected | Partial | RootTabs and StatusPill respect it. RootCanvas, TalkOrbOverlay, CameraFlashOverlay do not. |
|
||||
| Increased Contrast support | Partial | StatusGlassCard adjusts border for increased contrast. Other views do not check. |
|
||||
| Color not sole indicator | Pass | Status uses both color dots and text labels. |
|
||||
| Minimum touch target 44pt | Partial | Standard buttons OK. OverlayButton (~36pt) and StatusPill dot are undersized. |
|
||||
| Keyboard navigation (iPad) | Missing | No keyboard shortcuts defined. |
|
||||
| Localization readiness | Missing | All strings hardcoded in English. |
|
||||
| Haptic feedback | Missing | No haptic feedback in any interaction. |
|
||||
| iPad layout adaptation | Missing | No `horizontalSizeClass` or iPad-specific layouts. |
|
||||
| Dark mode support | Pass | Uses semantic colors, materials, and `.preferredColorScheme(.dark)` for canvas. |
|
||||
| Safe area handling | Pass | Correct use of `.ignoresSafeArea()` for screen, `.safeAreaPadding()` for overlays. |
|
||||
| Error state announcements | Missing | No `AccessibilityNotification.Announcement` for state changes. |
|
||||
| Focus management | Partial | `@FocusState` used in VoiceWakeWordsSettingsView. No focus management in onboarding. |
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Key Themes |
|
||||
|---|---|---|
|
||||
| Critical | 3 | Reduce Motion violations, flash accessibility |
|
||||
| High | 5 | Dynamic Type, localization, haptics, deprecated APIs, missing labels |
|
||||
| Medium | 8 | Monolithic views, empty states, touch targets, iPad, hardcoded sizes |
|
||||
| Low | 8 | Styling consistency, magic numbers, locale formatting, polish |
|
||||
@@ -164,7 +164,7 @@ actor CameraCaptureService {
|
||||
}
|
||||
|
||||
private func ensureAccess(for mediaType: AVMediaType) async throws {
|
||||
if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
|
||||
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,5 +9,4 @@ final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
queueLabel: "ai.openclaw.canvaswatcher",
|
||||
onChange: onChange))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -25,11 +25,22 @@ extension CanvasWindowController {
|
||||
}
|
||||
|
||||
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
|
||||
LoopbackHost.parseIPv4(host)
|
||||
let parts = host.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard parts.count == 4 else { return nil }
|
||||
let bytes: [UInt8] = parts.compactMap { UInt8($0) }
|
||||
guard bytes.count == 4 else { return nil }
|
||||
return (bytes[0], bytes[1], bytes[2], bytes[3])
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
|
||||
LoopbackHost.isLocalNetworkIPv4(ip)
|
||||
let (a, b, _, _) = ip
|
||||
if a == 10 { return true }
|
||||
if a == 172, (16...31).contains(Int(b)) { return true }
|
||||
if a == 192, b == 168 { return true }
|
||||
if a == 127 { return true }
|
||||
if a == 169, b == 254 { return true }
|
||||
if a == 100, (64...127).contains(Int(b)) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func _testIsLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
|
||||
@@ -30,5 +30,4 @@ final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
|
||||
},
|
||||
onChange: onChange))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -124,12 +124,12 @@ enum ExecSystemRunCommandValidator {
|
||||
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
|
||||
@@ -39,11 +39,12 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, Locatio
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
maxAgeMs: maxAgeMs,
|
||||
timeoutMs: timeoutMs,
|
||||
request: { try await self.requestLocationOnce() }) { timeoutMs, operation in
|
||||
request: { try await self.requestLocationOnce() },
|
||||
withTimeout: { timeoutMs, operation in
|
||||
try await self.withTimeout(timeoutMs: timeoutMs) {
|
||||
try await operation()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func withTimeout<T: Sendable>(
|
||||
|
||||
@@ -184,9 +184,9 @@ struct NodeMenuRowView: View {
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
Text(NodeMenuEntryFormatter.primaryName(self.entry))
|
||||
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
|
||||
.foregroundStyle(self.palette.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
@@ -195,9 +195,9 @@ struct NodeMenuRowView: View {
|
||||
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
Text(right)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.palette.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(2)
|
||||
|
||||
@@ -64,9 +64,10 @@ final class NotifyOverlayController {
|
||||
OverlayPanelFactory.present(
|
||||
window: self.window,
|
||||
isVisible: &self.model.isVisible,
|
||||
target: target) { window in
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
target: target)
|
||||
{ window in
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -134,10 +134,10 @@ extension OnboardingView {
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Refresh") {
|
||||
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
|
||||
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.help("Retry Tailscale discovery (DNS-SD).")
|
||||
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
@@ -311,7 +311,7 @@ extension OnboardingView {
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
@@ -134,7 +134,8 @@ enum PairingAlertSupport {
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
alertHostWindow: inout NSWindow?,
|
||||
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert {
|
||||
completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert
|
||||
{
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
let alert = NSAlert()
|
||||
@@ -164,32 +165,6 @@ enum PairingAlertSupport {
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
static func presentPairingAlert<Request>(
|
||||
request: Request,
|
||||
requestId: String,
|
||||
messageText: String,
|
||||
informativeText: String,
|
||||
activeAlert: inout NSAlert?,
|
||||
activeRequestId: inout String?,
|
||||
alertHostWindow: inout NSWindow?,
|
||||
clearActive: @escaping @MainActor (NSWindow) -> Void,
|
||||
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
|
||||
{
|
||||
self.presentPairingAlert(
|
||||
requestId: requestId,
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
activeAlert: &activeAlert,
|
||||
activeRequestId: &activeRequestId,
|
||||
alertHostWindow: &alertHostWindow)
|
||||
{ response, hostWindow in
|
||||
Task { @MainActor in
|
||||
clearActive(hostWindow)
|
||||
await onResponse(response, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func presentPairingAlert<Request>(
|
||||
request: Request,
|
||||
requestId: String,
|
||||
@@ -199,19 +174,18 @@ enum PairingAlertSupport {
|
||||
onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
|
||||
{
|
||||
self.presentPairingAlert(
|
||||
request: request,
|
||||
requestId: requestId,
|
||||
messageText: messageText,
|
||||
informativeText: informativeText,
|
||||
activeAlert: &state.activeAlert,
|
||||
activeRequestId: &state.activeRequestId,
|
||||
alertHostWindow: &state.alertHostWindow)
|
||||
{ response, hostWindow in
|
||||
Task { @MainActor in
|
||||
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
|
||||
await onResponse(response, request)
|
||||
}
|
||||
}
|
||||
alertHostWindow: &state.alertHostWindow,
|
||||
completion: { response, hostWindow in
|
||||
Task { @MainActor in
|
||||
self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
|
||||
await onResponse(response, request)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static func clearActivePairingAlert(
|
||||
@@ -231,12 +205,12 @@ enum PairingAlertSupport {
|
||||
hostWindow: hostWindow)
|
||||
}
|
||||
|
||||
static func stopPairingPrompter<Request>(
|
||||
static func stopPairingPrompter(
|
||||
isStopping: inout Bool,
|
||||
activeAlert: inout NSAlert?,
|
||||
activeRequestId: inout String?,
|
||||
task: inout Task<Void, Never>?,
|
||||
queue: inout [Request],
|
||||
queue: inout [some Any],
|
||||
isPresenting: inout Bool,
|
||||
alertHostWindow: inout NSWindow?)
|
||||
{
|
||||
@@ -252,10 +226,10 @@ enum PairingAlertSupport {
|
||||
alertHostWindow = nil
|
||||
}
|
||||
|
||||
static func stopPairingPrompter<Request>(
|
||||
static func stopPairingPrompter(
|
||||
isStopping: inout Bool,
|
||||
task: inout Task<Void, Never>?,
|
||||
queue: inout [Request],
|
||||
queue: inout [some Any],
|
||||
isPresenting: inout Bool,
|
||||
state: PairingAlertState)
|
||||
{
|
||||
|
||||
@@ -36,6 +36,7 @@ final class PeekabooBridgeHostCoordinator {
|
||||
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
return Self.legacySocketDirectoryNames.map { Self.makeSocketPath(for: $0, in: base) }
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
if enabled {
|
||||
await self.startIfNeeded()
|
||||
@@ -85,7 +86,7 @@ final class PeekabooBridgeHostCoordinator {
|
||||
}
|
||||
|
||||
private func ensureLegacySocketSymlinks() {
|
||||
Self.legacySocketPaths.forEach { legacyPath in
|
||||
for legacyPath in Self.legacySocketPaths {
|
||||
self.ensureLegacySocketSymlink(at: legacyPath)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +117,9 @@ final class PeekabooBridgeHostCoordinator {
|
||||
}
|
||||
try fileManager.createSymbolicLink(atPath: legacyPath, withDestinationPath: Self.openclawSocketPath)
|
||||
} catch {
|
||||
self.logger.debug("Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription, privacy: .public)")
|
||||
let message = "Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription)"
|
||||
self.logger
|
||||
.debug("\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@ final class TalkOverlayController {
|
||||
OverlayPanelFactory.present(
|
||||
window: self.window,
|
||||
isVisible: &self.model.isVisible,
|
||||
target: target) { window in
|
||||
window.setFrame(target, display: true)
|
||||
window.orderFrontRegardless()
|
||||
target: target)
|
||||
{ window in
|
||||
window.setFrame(target, display: true)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,14 +77,14 @@ struct GatewayCostUsageDay: Codable {
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.date = try c.decode(String.self, forKey: .date)
|
||||
self.totals = GatewayCostUsageTotals(
|
||||
input: try c.decode(Int.self, forKey: .input),
|
||||
output: try c.decode(Int.self, forKey: .output),
|
||||
cacheRead: try c.decode(Int.self, forKey: .cacheRead),
|
||||
cacheWrite: try c.decode(Int.self, forKey: .cacheWrite),
|
||||
totalTokens: try c.decode(Int.self, forKey: .totalTokens),
|
||||
totalCost: try c.decode(Double.self, forKey: .totalCost),
|
||||
missingCostEntries: try c.decode(Int.self, forKey: .missingCostEntries))
|
||||
self.totals = try GatewayCostUsageTotals(
|
||||
input: c.decode(Int.self, forKey: .input),
|
||||
output: c.decode(Int.self, forKey: .output),
|
||||
cacheRead: c.decode(Int.self, forKey: .cacheRead),
|
||||
cacheWrite: c.decode(Int.self, forKey: .cacheWrite),
|
||||
totalTokens: c.decode(Int.self, forKey: .totalTokens),
|
||||
totalCost: c.decode(Double.self, forKey: .totalCost),
|
||||
missingCostEntries: c.decode(Int.self, forKey: .missingCostEntries))
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
|
||||
@@ -172,9 +172,9 @@ actor VoicePushToTalk {
|
||||
let adoptedPrefix = self.adoptedPrefix
|
||||
let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : VoiceOverlayTextFormatting
|
||||
.makeAttributed(
|
||||
committed: adoptedPrefix,
|
||||
volatile: "",
|
||||
isFinal: false)
|
||||
committed: adoptedPrefix,
|
||||
volatile: "",
|
||||
isFinal: false)
|
||||
self.overlayToken = await MainActor.run {
|
||||
VoiceSessionCoordinator.shared.startSession(
|
||||
source: .pushToTalk,
|
||||
@@ -406,5 +406,4 @@ actor VoicePushToTalk {
|
||||
if suffix.isEmpty { return prefix }
|
||||
return "\(prefix) \(suffix)"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ extension VoiceWakeOverlayController {
|
||||
"overlay present windowShown textLen=\(self.model.text.count, privacy: .public)")
|
||||
// Keep the status item in “listening” mode until we explicitly dismiss the overlay.
|
||||
AppStateStore.shared.triggerVoiceEars(ttl: nil)
|
||||
}) { window in
|
||||
},
|
||||
onAlreadyVisible: { window in
|
||||
self.updateWindowFrame(animate: true)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func ensureWindow() {
|
||||
|
||||
@@ -773,5 +773,4 @@ actor VoiceWakeRuntime {
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ public final class GatewayDiscoveryModel {
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||
private var tailscaleServeFallbackTask: Task<Void, Never>?
|
||||
private var tailscaleServeFallbackGateways: [DiscoveredGateway] = []
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
|
||||
|
||||
public init(
|
||||
@@ -111,6 +113,7 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
self.scheduleWideAreaFallback()
|
||||
self.scheduleTailscaleServeFallback()
|
||||
}
|
||||
|
||||
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
@@ -126,6 +129,23 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshTailscaleServeFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRemoteFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
self.refreshWideAreaFallbackNow(timeoutSeconds: timeoutSeconds)
|
||||
self.refreshTailscaleServeFallbackNow(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
for browser in self.browsers.values {
|
||||
browser.cancel()
|
||||
@@ -140,6 +160,9 @@ public final class GatewayDiscoveryModel {
|
||||
self.wideAreaFallbackTask?.cancel()
|
||||
self.wideAreaFallbackTask = nil
|
||||
self.wideAreaFallbackGateways = []
|
||||
self.tailscaleServeFallbackTask?.cancel()
|
||||
self.tailscaleServeFallbackTask = nil
|
||||
self.tailscaleServeFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
@@ -168,22 +191,45 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func mapTailscaleServeBeacons(
|
||||
_ beacons: [TailscaleServeGatewayBeacon]) -> [DiscoveredGateway]
|
||||
{
|
||||
beacons.map { beacon in
|
||||
let stableID = "tailscale-serve|\(beacon.tailnetDns.lowercased())"
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
displayName: beacon.displayName,
|
||||
serviceName: nil,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: beacon.displayName,
|
||||
serviceHost: beacon.host,
|
||||
servicePort: beacon.port,
|
||||
lanHost: nil,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
sshPort: 22,
|
||||
gatewayPort: beacon.port,
|
||||
cliPath: nil,
|
||||
stableID: stableID,
|
||||
debugID: "\(beacon.host):\(beacon.port)",
|
||||
isLocal: isLocal)
|
||||
}
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
|
||||
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
|
||||
if !primaryFiltered.isEmpty {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
||||
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
|
||||
guard !self.wideAreaFallbackGateways.isEmpty else {
|
||||
// and cross-network setups may rely on Tailscale Serve without DNS-SD.
|
||||
let fallback = self.wideAreaFallbackGateways + self.tailscaleServeFallbackGateways
|
||||
guard !fallback.isEmpty else {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
|
||||
let combined = self.sortedDeduped(gateways: primary + fallback)
|
||||
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
||||
}
|
||||
|
||||
@@ -284,6 +330,39 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTailscaleServeFallback() {
|
||||
if Self.isRunningTests { return }
|
||||
guard self.tailscaleServeFallbackTask == nil else { return }
|
||||
self.tailscaleServeFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
var attempt = 0
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||
let hasResults = await MainActor.run {
|
||||
if self.filterLocalGateways {
|
||||
return !self.gateways.isEmpty
|
||||
}
|
||||
return self.gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
if hasResults { return }
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
|
||||
if !beacons.isEmpty {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
let backoff = min(8.0, 0.8 + (Double(attempt) * 0.8))
|
||||
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasUsableWideAreaResults: Bool {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
|
||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||
@@ -291,11 +370,25 @@ public final class GatewayDiscoveryModel {
|
||||
return gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
|
||||
static func dedupeKey(for gateway: DiscoveredGateway) -> String {
|
||||
if let host = gateway.serviceHost?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased(),
|
||||
!host.isEmpty,
|
||||
let port = gateway.servicePort,
|
||||
port > 0
|
||||
{
|
||||
return "endpoint|\(host):\(port)"
|
||||
}
|
||||
return "stable|\(gateway.stableID)"
|
||||
}
|
||||
|
||||
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
|
||||
var seen = Set<String>()
|
||||
let deduped = gateways.filter { gateway in
|
||||
if seen.contains(gateway.stableID) { return false }
|
||||
seen.insert(gateway.stableID)
|
||||
let key = Self.dedupeKey(for: gateway)
|
||||
if seen.contains(key) { return false }
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
return deduped.sorted {
|
||||
|
||||
@@ -13,8 +13,8 @@ public enum TailscaleNetwork {
|
||||
}
|
||||
|
||||
public static func detectTailnetIPv4() -> String? {
|
||||
for entry in NetworkInterfaceIPv4.addresses() {
|
||||
if self.isTailnetIPv4(entry.ip) { return entry.ip }
|
||||
for entry in NetworkInterfaceIPv4.addresses() where self.isTailnetIPv4(entry.ip) {
|
||||
return entry.ip
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TailscaleServeGatewayBeacon: Sendable, Equatable {
|
||||
var displayName: String
|
||||
var tailnetDns: String
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
|
||||
enum TailscaleServeGatewayDiscovery {
|
||||
private static let maxCandidates = 32
|
||||
private static let probeConcurrency = 6
|
||||
private static let defaultProbeTimeoutSeconds: TimeInterval = 1.6
|
||||
|
||||
struct DiscoveryContext: Sendable {
|
||||
var tailscaleStatus: @Sendable () async -> String?
|
||||
var probeHost: @Sendable (_ host: String, _ timeout: TimeInterval) async -> Bool
|
||||
|
||||
static let live = DiscoveryContext(
|
||||
tailscaleStatus: { await readTailscaleStatus() },
|
||||
probeHost: { host, timeout in
|
||||
await probeHostForGatewayChallenge(host: host, timeout: timeout)
|
||||
})
|
||||
}
|
||||
|
||||
static func discover(
|
||||
timeoutSeconds: TimeInterval = 3.0,
|
||||
context: DiscoveryContext = .live) async -> [TailscaleServeGatewayBeacon]
|
||||
{
|
||||
guard timeoutSeconds > 0 else { return [] }
|
||||
guard let statusJson = await context.tailscaleStatus(),
|
||||
let status = parseStatus(statusJson)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
|
||||
let candidates = self.collectCandidates(status: status)
|
||||
if candidates.isEmpty { return [] }
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeoutSeconds)
|
||||
let perProbeTimeout = min(self.defaultProbeTimeoutSeconds, max(0.5, timeoutSeconds * 0.45))
|
||||
|
||||
var byHost: [String: TailscaleServeGatewayBeacon] = [:]
|
||||
await withTaskGroup(of: TailscaleServeGatewayBeacon?.self) { group in
|
||||
var index = 0
|
||||
let workerCount = min(self.probeConcurrency, candidates.count)
|
||||
|
||||
func submitOne() {
|
||||
guard index < candidates.count else { return }
|
||||
let candidate = candidates[index]
|
||||
index += 1
|
||||
group.addTask {
|
||||
let remaining = deadline.timeIntervalSinceNow
|
||||
if remaining <= 0 {
|
||||
return nil
|
||||
}
|
||||
let timeout = min(perProbeTimeout, remaining)
|
||||
let reachable = await context.probeHost(candidate.dnsName, timeout)
|
||||
if !reachable {
|
||||
return nil
|
||||
}
|
||||
return TailscaleServeGatewayBeacon(
|
||||
displayName: candidate.displayName,
|
||||
tailnetDns: candidate.dnsName,
|
||||
host: candidate.dnsName,
|
||||
port: 443)
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..<workerCount {
|
||||
submitOne()
|
||||
}
|
||||
|
||||
while let beacon = await group.next() {
|
||||
if let beacon {
|
||||
byHost[beacon.host.lowercased()] = beacon
|
||||
}
|
||||
submitOne()
|
||||
}
|
||||
}
|
||||
|
||||
return byHost.values.sorted {
|
||||
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private struct Candidate: Sendable {
|
||||
var dnsName: String
|
||||
var displayName: String
|
||||
}
|
||||
|
||||
private static func collectCandidates(status: TailscaleStatus) -> [Candidate] {
|
||||
let selfDns = normalizeDnsName(status.selfNode?.dnsName)
|
||||
var out: [Candidate] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
for node in status.peer.values {
|
||||
if node.online == false {
|
||||
continue
|
||||
}
|
||||
guard let dnsName = normalizeDnsName(node.dnsName) else {
|
||||
continue
|
||||
}
|
||||
if dnsName == selfDns {
|
||||
continue
|
||||
}
|
||||
if seen.contains(dnsName) {
|
||||
continue
|
||||
}
|
||||
seen.insert(dnsName)
|
||||
|
||||
out.append(Candidate(
|
||||
dnsName: dnsName,
|
||||
displayName: displayName(hostName: node.hostName, dnsName: dnsName)))
|
||||
|
||||
if out.count >= self.maxCandidates {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
private static func displayName(hostName: String?, dnsName: String) -> String {
|
||||
if let hostName {
|
||||
let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return dnsName
|
||||
.split(separator: ".")
|
||||
.first
|
||||
.map(String.init) ?? dnsName
|
||||
}
|
||||
|
||||
private static func normalizeDnsName(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let withoutDot = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||
let lower = withoutDot.lowercased()
|
||||
return lower.isEmpty ? nil : lower
|
||||
}
|
||||
|
||||
private static func readTailscaleStatus() async -> String? {
|
||||
let candidates = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
"tailscale",
|
||||
]
|
||||
|
||||
for candidate in candidates {
|
||||
guard let executable = self.resolveExecutablePath(candidate) else { continue }
|
||||
if let stdout = await self.run(path: executable, args: ["status", "--json"], timeout: 1.0) {
|
||||
return stdout
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func resolveExecutablePath(
|
||||
_ candidate: String,
|
||||
env: [String: String] = ProcessInfo.processInfo.environment) -> String?
|
||||
{
|
||||
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let hasPathSeparator = trimmed.contains("/")
|
||||
if hasPathSeparator {
|
||||
return fileManager.isExecutableFile(atPath: trimmed) ? trimmed : nil
|
||||
}
|
||||
|
||||
let pathRaw = env["PATH"] ?? ""
|
||||
let entries = pathRaw.split(separator: ":").map(String.init)
|
||||
for entry in entries {
|
||||
let dir = entry.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if dir.isEmpty { continue }
|
||||
let fullPath = URL(fileURLWithPath: dir)
|
||||
.appendingPathComponent(trimmed)
|
||||
.path
|
||||
if fileManager.isExecutableFile(atPath: fullPath) {
|
||||
return fullPath
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func run(path: String, args: [String], timeout: TimeInterval) async -> String? {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
continuation.resume(returning: self.runBlocking(path: path, args: args, timeout: timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func runBlocking(path: String, args: [String], timeout: TimeInterval) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = args
|
||||
let outPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while process.isRunning, Date() < deadline {
|
||||
Thread.sleep(forTimeInterval: 0.02)
|
||||
}
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data()
|
||||
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return output?.isEmpty == false ? output : nil
|
||||
}
|
||||
|
||||
private static func parseStatus(_ raw: String) -> TailscaleStatus? {
|
||||
guard let data = raw.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(TailscaleStatus.self, from: data)
|
||||
}
|
||||
|
||||
private static func probeHostForGatewayChallenge(host: String, timeout: TimeInterval) async -> Bool {
|
||||
var components = URLComponents()
|
||||
components.scheme = "wss"
|
||||
components.host = host
|
||||
guard let url = components.url else { return false }
|
||||
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = max(0.5, timeout)
|
||||
config.timeoutIntervalForResource = max(0.5, timeout)
|
||||
let session = URLSession(configuration: config)
|
||||
let task = session.webSocketTask(with: url)
|
||||
task.resume()
|
||||
|
||||
defer {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
session.invalidateAndCancel()
|
||||
}
|
||||
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeout(
|
||||
seconds: timeout,
|
||||
onTimeout: { NSError(domain: "TailscaleServeDiscovery", code: 1, userInfo: nil) },
|
||||
operation: {
|
||||
while true {
|
||||
let message = try await task.receive()
|
||||
if isConnectChallenge(message: message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func isConnectChallenge(message: URLSessionWebSocketTask.Message) -> Bool {
|
||||
let data: Data
|
||||
switch message {
|
||||
case let .data(value):
|
||||
data = value
|
||||
case let .string(value):
|
||||
guard let encoded = value.data(using: .utf8) else { return false }
|
||||
data = encoded
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data),
|
||||
let dict = object as? [String: Any],
|
||||
let type = dict["type"] as? String,
|
||||
type == "event",
|
||||
let event = dict["event"] as? String
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
return event == "connect.challenge"
|
||||
}
|
||||
}
|
||||
|
||||
private struct TailscaleStatus: Decodable {
|
||||
struct Node: Decodable {
|
||||
let dnsName: String?
|
||||
let hostName: String?
|
||||
let online: Bool?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case dnsName = "DNSName"
|
||||
case hostName = "HostName"
|
||||
case online = "Online"
|
||||
}
|
||||
}
|
||||
|
||||
let selfNode: Node?
|
||||
let peer: [String: Node]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case selfNode = "Self"
|
||||
case peer = "Peer"
|
||||
}
|
||||
}
|
||||
@@ -1030,6 +1030,74 @@ public struct PushTestResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecretsReloadParams: Codable, Sendable {}
|
||||
|
||||
public struct SecretsResolveParams: Codable, Sendable {
|
||||
public let commandname: String
|
||||
public let targetids: [String]
|
||||
|
||||
public init(
|
||||
commandname: String,
|
||||
targetids: [String])
|
||||
{
|
||||
self.commandname = commandname
|
||||
self.targetids = targetids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case commandname = "commandName"
|
||||
case targetids = "targetIds"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecretsResolveAssignment: Codable, Sendable {
|
||||
public let path: String?
|
||||
public let pathsegments: [String]
|
||||
public let value: AnyCodable
|
||||
|
||||
public init(
|
||||
path: String?,
|
||||
pathsegments: [String],
|
||||
value: AnyCodable)
|
||||
{
|
||||
self.path = path
|
||||
self.pathsegments = pathsegments
|
||||
self.value = value
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case pathsegments = "pathSegments"
|
||||
case value
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecretsResolveResult: Codable, Sendable {
|
||||
public let ok: Bool?
|
||||
public let assignments: [SecretsResolveAssignment]?
|
||||
public let diagnostics: [String]?
|
||||
public let inactiverefpaths: [String]?
|
||||
|
||||
public init(
|
||||
ok: Bool?,
|
||||
assignments: [SecretsResolveAssignment]?,
|
||||
diagnostics: [String]?,
|
||||
inactiverefpaths: [String]?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.assignments = assignments
|
||||
self.diagnostics = diagnostics
|
||||
self.inactiverefpaths = inactiverefpaths
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case assignments
|
||||
case diagnostics
|
||||
case inactiverefpaths = "inactiveRefPaths"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import OpenClawDiscovery
|
||||
@testable import OpenClawDiscovery
|
||||
import Testing
|
||||
|
||||
@Suite
|
||||
@@ -121,4 +121,50 @@ struct GatewayDiscoveryModelTests {
|
||||
host: "studio.local",
|
||||
port: 2201) == "peter@studio.local:2201")
|
||||
}
|
||||
|
||||
@Test func dedupeKeyPrefersResolvedEndpointAcrossSources() {
|
||||
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host",
|
||||
debugID: "wide-area",
|
||||
isLocal: false)
|
||||
let serve = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: wideArea) == GatewayDiscoveryModel.dedupeKey(for: serve))
|
||||
}
|
||||
|
||||
@Test func dedupeKeyFallsBackToStableIDWithoutEndpoint() {
|
||||
let unresolved = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawDiscovery
|
||||
|
||||
@Suite
|
||||
struct TailscaleServeGatewayDiscoveryTests {
|
||||
@Test func discoversServeGatewayFromTailnetPeers() async {
|
||||
let statusJson = """
|
||||
{
|
||||
"Self": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
},
|
||||
"Peer": {
|
||||
"peer-1": {
|
||||
"DNSName": "gateway-host.tailnet-example.ts.net.",
|
||||
"HostName": "gateway-host",
|
||||
"Online": true
|
||||
},
|
||||
"peer-2": {
|
||||
"DNSName": "offline.tailnet-example.ts.net.",
|
||||
"HostName": "offline-box",
|
||||
"Online": false
|
||||
},
|
||||
"peer-3": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { statusJson },
|
||||
probeHost: { host, _ in
|
||||
host == "gateway-host.tailnet-example.ts.net"
|
||||
})
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.count == 1)
|
||||
#expect(beacons.first?.displayName == "gateway-host")
|
||||
#expect(beacons.first?.tailnetDns == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.host == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.port == 443)
|
||||
}
|
||||
|
||||
@Test func returnsEmptyWhenStatusUnavailable() async {
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { nil },
|
||||
probeHost: { _, _ in true })
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolvesBareExecutableFromPATH() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let executable = tempDir.appendingPathComponent("tailscale")
|
||||
try "#!/bin/sh\necho ok\n".write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let env: [String: String] = ["PATH": tempDir.path]
|
||||
let resolved = TailscaleServeGatewayDiscovery.resolveExecutablePath("tailscale", env: env)
|
||||
#expect(resolved == executable.path)
|
||||
}
|
||||
|
||||
@Test func rejectsMissingExecutableCandidate() {
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("", env: [:]) == nil)
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ extension URLSession: WebSocketSessioning {
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.webSocketTask(with: url)
|
||||
// Avoid "Message too long" receive errors for large snapshots / history payloads.
|
||||
task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB
|
||||
task.maximumMessageSize = 4 * 1024 * 1024 // 4 MB
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,23 +17,70 @@ public struct GatewayTLSParams: Sendable {
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
private static let suiteName = "ai.openclaw.shared"
|
||||
private static let keyPrefix = "gateway.tls."
|
||||
private static let keychainService = "ai.openclaw.tls-pinning"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
// Legacy UserDefaults location used before Keychain migration.
|
||||
private static let legacySuiteName = "ai.openclaw.shared"
|
||||
private static let legacyKeyPrefix = "gateway.tls."
|
||||
|
||||
public static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = self.keyPrefix + stableID
|
||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.migrateFromUserDefaultsIfNeeded(stableID: stableID)
|
||||
let raw = self.keychainLoad(account: stableID)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if raw?.isEmpty == false { return raw }
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = self.keyPrefix + stableID
|
||||
self.defaults.set(value, forKey: key)
|
||||
self.keychainSave(value, account: stableID)
|
||||
}
|
||||
|
||||
// MARK: - Migration
|
||||
|
||||
/// On first Keychain read for a given stableID, move any legacy UserDefaults
|
||||
/// fingerprint into Keychain and remove the old entry.
|
||||
private static func migrateFromUserDefaultsIfNeeded(stableID: String) {
|
||||
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
|
||||
let legacyKey = self.legacyKeyPrefix + stableID
|
||||
guard let existing = defaults.string(forKey: legacyKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!existing.isEmpty
|
||||
else { return }
|
||||
if self.keychainLoad(account: stableID) == nil {
|
||||
guard self.keychainSave(existing, account: stableID) else { return }
|
||||
}
|
||||
defaults.removeObject(forKey: legacyKey)
|
||||
}
|
||||
|
||||
// MARK: - Self-contained Keychain helpers (OpenClawKit can't import iOS KeychainStore)
|
||||
|
||||
private static func keychainLoad(account: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func keychainSave(_ value: String, account: String) -> Bool {
|
||||
let data = Data(value.utf8)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: self.keychainService,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
// Delete-then-add to enforce accessibility attribute.
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +99,7 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
|
||||
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
task.maximumMessageSize = 4 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
|
||||
@@ -1030,6 +1030,74 @@ public struct PushTestResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecretsReloadParams: Codable, Sendable {}
|
||||
|
||||
public struct SecretsResolveParams: Codable, Sendable {
|
||||
public let commandname: String
|
||||
public let targetids: [String]
|
||||
|
||||
public init(
|
||||
commandname: String,
|
||||
targetids: [String])
|
||||
{
|
||||
self.commandname = commandname
|
||||
self.targetids = targetids
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case commandname = "commandName"
|
||||
case targetids = "targetIds"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecretsResolveAssignment: Codable, Sendable {
|
||||
public let path: String?
|
||||
public let pathsegments: [String]
|
||||
public let value: AnyCodable
|
||||
|
||||
public init(
|
||||
path: String?,
|
||||
pathsegments: [String],
|
||||
value: AnyCodable)
|
||||
{
|
||||
self.path = path
|
||||
self.pathsegments = pathsegments
|
||||
self.value = value
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case pathsegments = "pathSegments"
|
||||
case value
|
||||
}
|
||||
}
|
||||
|
||||
public struct SecretsResolveResult: Codable, Sendable {
|
||||
public let ok: Bool?
|
||||
public let assignments: [SecretsResolveAssignment]?
|
||||
public let diagnostics: [String]?
|
||||
public let inactiverefpaths: [String]?
|
||||
|
||||
public init(
|
||||
ok: Bool?,
|
||||
assignments: [SecretsResolveAssignment]?,
|
||||
diagnostics: [String]?,
|
||||
inactiverefpaths: [String]?)
|
||||
{
|
||||
self.ok = ok
|
||||
self.assignments = assignments
|
||||
self.diagnostics = diagnostics
|
||||
self.inactiverefpaths = inactiverefpaths
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ok
|
||||
case assignments
|
||||
case diagnostics
|
||||
case inactiverefpaths = "inactiveRefPaths"
|
||||
}
|
||||
}
|
||||
|
||||
public struct SessionsListParams: Codable, Sendable {
|
||||
public let limit: Int?
|
||||
public let activeminutes: Int?
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Changelog Fragments
|
||||
|
||||
Use this directory when a PR should not edit `CHANGELOG.md` directly.
|
||||
|
||||
- One fragment file per PR.
|
||||
- File name recommendation: `pr-<number>.md`.
|
||||
- Include at least one line with both `#<pr>` and `thanks @<contributor>`.
|
||||
|
||||
Example:
|
||||
|
||||
```md
|
||||
- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice)
|
||||
```
|
||||
@@ -1 +0,0 @@
|
||||
- Tlon plugin: sync upstream account/settings workflows, restore SSRF-safe media + SSE fetch paths, and improve invite/approval handling reliability. (#21208) (thanks @arthyn)
|
||||
@@ -1 +0,0 @@
|
||||
- Clarify block reply pipeline seen-check parameter naming for maintainability (#5080) (thanks @yassine20011)
|
||||
@@ -1 +0,0 @@
|
||||
- Memory flush: fix usage-threshold gating and transcript fallback paths so flushes run reliably when expected (#5343) (thanks @jarvis-medmatic)
|
||||
@@ -36,6 +36,7 @@ OpenClaw uses Brave Search as the default provider for `web_search`.
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
|
||||
- Brave provides paid plans; check the Brave API portal for current limits.
|
||||
- Brave Terms include restrictions on some AI-related uses of Search Results. Review the Brave Terms of Service and confirm your intended use is compliant. For legal questions, consult your counsel.
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
|
||||
@@ -17,6 +17,7 @@ host configuration.
|
||||
- **AccountId**: per‑channel account instance (when supported).
|
||||
- Optional channel default account: `channels.<channel>.defaultAccount` chooses
|
||||
which account is used when an outbound path does not specify `accountId`.
|
||||
- In multi-account setups, set an explicit default (`defaultAccount` or `accounts.default`) when two or more accounts are configured. Without it, fallback routing may pick the first normalized account ID.
|
||||
- **AgentId**: an isolated workspace + session store (“brain”).
|
||||
- **SessionKey**: the bucket key used to store context and control concurrency.
|
||||
|
||||
|
||||
@@ -197,6 +197,17 @@ Edit `~/.openclaw/openclaw.json`:
|
||||
|
||||
If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address.
|
||||
|
||||
#### Verification Token (webhook mode)
|
||||
|
||||
When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value:
|
||||
|
||||
1. In Feishu Open Platform, open your app
|
||||
2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调)
|
||||
3. Open the **Encryption** tab (加密策略)
|
||||
4. Copy **Verification Token**
|
||||
|
||||

|
||||
|
||||
### Configure via environment variables
|
||||
|
||||
```bash
|
||||
@@ -359,9 +370,9 @@ After approval, you can chat normally.
|
||||
}
|
||||
```
|
||||
|
||||
### Allow specific users to run control commands in a group (e.g. /reset, /new)
|
||||
### Restrict which senders can message in a group (sender allowlist)
|
||||
|
||||
In addition to allowing the group itself, control commands are gated by the **sender** open_id.
|
||||
In addition to allowing the group itself, **all messages** in that group are gated by the sender open_id: only users listed in `groups.<chat_id>.allowFrom` have their messages processed; messages from other members are ignored (this is full sender-level gating, not only for control commands like /reset or /new).
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -55,6 +55,45 @@ Minimal config:
|
||||
}
|
||||
```
|
||||
|
||||
## Native slash commands
|
||||
|
||||
Native slash commands are opt-in. When enabled, OpenClaw registers `oc_*` slash commands via
|
||||
the Mattermost API and receives callback POSTs on the gateway HTTP server.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
native: true,
|
||||
nativeSkills: true,
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
// Use when Mattermost cannot reach the gateway directly (reverse proxy/public URL).
|
||||
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `native: "auto"` defaults to disabled for Mattermost. Set `native: true` to enable.
|
||||
- If `callbackUrl` is omitted, OpenClaw derives one from gateway host/port + `callbackPath`.
|
||||
- For multi-account setups, `commands` can be set at the top level or under
|
||||
`channels.mattermost.accounts.<id>.commands` (account values override top-level fields).
|
||||
- Command callbacks are validated with per-command tokens and fail closed when token checks fail.
|
||||
- Reachability requirement: the callback endpoint must be reachable from the Mattermost server.
|
||||
- Do not set `callbackUrl` to `localhost` unless Mattermost runs on the same host/network namespace as OpenClaw.
|
||||
- Do not set `callbackUrl` to your Mattermost base URL unless that URL reverse-proxies `/api/channels/mattermost/command` to OpenClaw.
|
||||
- A quick check is `curl https://<gateway-host>/api/channels/mattermost/command`; a GET should return `405 Method Not Allowed` from OpenClaw, not `404`.
|
||||
- Mattermost egress allowlist requirement:
|
||||
- If your callback targets private/tailnet/internal addresses, set Mattermost
|
||||
`ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
|
||||
- Use host/domain entries, not full URLs.
|
||||
- Good: `gateway.tailnet-name.ts.net`
|
||||
- Bad: `https://gateway.tailnet-name.ts.net`
|
||||
|
||||
## Environment variables (default account)
|
||||
|
||||
Set these on the gateway host if you prefer env vars:
|
||||
|
||||
@@ -739,6 +739,8 @@ Primary reference:
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`).
|
||||
- Multi-account precedence:
|
||||
- When two or more account IDs are configured, set `channels.telegram.defaultAccount` (or include `channels.telegram.accounts.default`) to make default routing explicit.
|
||||
- If neither is set, OpenClaw falls back to the first normalized account ID and `openclaw doctor` warns.
|
||||
- `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account.
|
||||
- Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset.
|
||||
- Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`.
|
||||
|
||||
32
docs/ci.md
32
docs/ci.md
@@ -13,20 +13,20 @@ The CI runs on every push to `main` and every pull request. It uses smart scopin
|
||||
|
||||
## Job Overview
|
||||
|
||||
| Job | Purpose | When it runs |
|
||||
| ----------------- | ----------------------------------------------- | ------------------------- |
|
||||
| `docs-scope` | Detect docs-only changes | Always |
|
||||
| `changed-scope` | Detect which areas changed (node/macos/android) | Non-docs PRs |
|
||||
| `check` | TypeScript types, lint, format | Non-docs changes |
|
||||
| `check-docs` | Markdown lint + broken link check | Docs changed |
|
||||
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
|
||||
| `secrets` | Detect leaked secrets | Always |
|
||||
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
|
||||
| `release-check` | Validate npm pack contents | After build |
|
||||
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
|
||||
| `checks-windows` | Windows-specific tests | Non-docs, node changes |
|
||||
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
|
||||
| `android` | Gradle build + tests | Non-docs, android changes |
|
||||
| Job | Purpose | When it runs |
|
||||
| ----------------- | ------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `docs-scope` | Detect docs-only changes | Always |
|
||||
| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-docs PRs |
|
||||
| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes |
|
||||
| `check-docs` | Markdown lint + broken link check | Docs changed |
|
||||
| `code-analysis` | LOC threshold check (1000 lines) | PRs only |
|
||||
| `secrets` | Detect leaked secrets | Always |
|
||||
| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes |
|
||||
| `release-check` | Validate npm pack contents | After build |
|
||||
| `checks` | Node/Bun tests + protocol check | Non-docs, node changes |
|
||||
| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes |
|
||||
| `macos` | Swift lint/build/test + TS tests | PRs with macos changes |
|
||||
| `android` | Gradle build + tests | Non-docs, android changes |
|
||||
|
||||
## Fail-Fast Order
|
||||
|
||||
@@ -36,12 +36,14 @@ Jobs are ordered so cheap checks fail before expensive ones run:
|
||||
2. `build-artifacts` (blocked on above)
|
||||
3. `checks`, `checks-windows`, `macos`, `android` (blocked on build)
|
||||
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`.
|
||||
|
||||
## Runners
|
||||
|
||||
| Runner | Jobs |
|
||||
| -------------------------------- | ------------------------------------------ |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | Most Linux jobs, including scope detection |
|
||||
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-32vcpu-windows-2025` | `checks-windows` |
|
||||
| `macos-latest` | `macos`, `ios` |
|
||||
|
||||
## Local Equivalents
|
||||
|
||||
@@ -50,3 +50,5 @@ Notes:
|
||||
- `memory status --deep --index` runs a reindex if the store is dirty.
|
||||
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
||||
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
|
||||
- If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast.
|
||||
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
|
||||
|
||||
@@ -34,6 +34,9 @@ openclaw qr --url wss://gateway.example/ws --token '<token>'
|
||||
## Notes
|
||||
|
||||
- `--token` and `--password` are mutually exclusive.
|
||||
- With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast.
|
||||
- Without `--remote`, local `gateway.auth.password` SecretRefs are resolved when password auth can win (explicit `gateway.auth.mode="password"` or inferred password mode with no winning token from auth/env), and no CLI auth override is passed.
|
||||
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
|
||||
- After scanning, approve device pairing with:
|
||||
- `openclaw devices list`
|
||||
- `openclaw devices approve <requestId>`
|
||||
|
||||
@@ -9,14 +9,14 @@ title: "secrets"
|
||||
|
||||
# `openclaw secrets`
|
||||
|
||||
Use `openclaw secrets` to migrate credentials from plaintext to SecretRefs and keep the active secrets runtime healthy.
|
||||
Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot healthy.
|
||||
|
||||
Command roles:
|
||||
|
||||
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
|
||||
- `audit`: read-only scan of config + auth stores + legacy residues (`.env`, `auth.json`) for plaintext, unresolved refs, and precedence drift.
|
||||
- `configure`: interactive planner for provider setup + target mapping + preflight (TTY required).
|
||||
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub migrated plaintext residues.
|
||||
- `audit`: read-only scan of configuration/auth stores and legacy residues for plaintext, unresolved refs, and precedence drift.
|
||||
- `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required).
|
||||
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues.
|
||||
|
||||
Recommended operator loop:
|
||||
|
||||
@@ -31,11 +31,13 @@ openclaw secrets reload
|
||||
|
||||
Exit code note for CI/gates:
|
||||
|
||||
- `audit --check` returns `1` on findings, `2` when refs are unresolved.
|
||||
- `audit --check` returns `1` on findings.
|
||||
- unresolved refs return `2`.
|
||||
|
||||
Related:
|
||||
|
||||
- Secrets guide: [Secrets Management](/gateway/secrets)
|
||||
- Credential surface: [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
- Security guide: [Security](/gateway/security)
|
||||
|
||||
## Reload runtime snapshot
|
||||
@@ -59,8 +61,8 @@ Scan OpenClaw state for:
|
||||
|
||||
- plaintext secret storage
|
||||
- unresolved refs
|
||||
- precedence drift (`auth-profiles` shadowing config refs)
|
||||
- legacy residues (`auth.json`, OAuth out-of-scope notes)
|
||||
- precedence drift (`auth-profiles.json` credentials shadowing `openclaw.json` refs)
|
||||
- legacy residues (legacy auth store entries, OAuth reminders)
|
||||
|
||||
```bash
|
||||
openclaw secrets audit
|
||||
@@ -71,7 +73,7 @@ openclaw secrets audit --json
|
||||
Exit behavior:
|
||||
|
||||
- `--check` exits non-zero on findings.
|
||||
- unresolved refs exit with a higher-priority non-zero code.
|
||||
- unresolved refs exit with higher-priority non-zero code.
|
||||
|
||||
Report shape highlights:
|
||||
|
||||
@@ -85,7 +87,7 @@ Report shape highlights:
|
||||
|
||||
## Configure (interactive helper)
|
||||
|
||||
Build provider + SecretRef changes interactively, run preflight, and optionally apply:
|
||||
Build provider and SecretRef changes interactively, run preflight, and optionally apply:
|
||||
|
||||
```bash
|
||||
openclaw secrets configure
|
||||
@@ -93,6 +95,7 @@ openclaw secrets configure --plan-out /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets configure --apply --yes
|
||||
openclaw secrets configure --providers-only
|
||||
openclaw secrets configure --skip-provider-setup
|
||||
openclaw secrets configure --agent ops
|
||||
openclaw secrets configure --json
|
||||
```
|
||||
|
||||
@@ -106,23 +109,26 @@ Flags:
|
||||
|
||||
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
|
||||
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
|
||||
- `--agent <id>`: scope `auth-profiles.json` target discovery and writes to one agent store.
|
||||
|
||||
Notes:
|
||||
|
||||
- Requires an interactive TTY.
|
||||
- You cannot combine `--providers-only` with `--skip-provider-setup`.
|
||||
- `configure` targets secret-bearing fields in `openclaw.json`.
|
||||
- Include all secret-bearing fields you intend to migrate (for example both `models.providers.*.apiKey` and `skills.entries.*.apiKey`) so audit can reach a clean state.
|
||||
- `configure` targets secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for the selected agent scope.
|
||||
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
|
||||
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
|
||||
- It performs preflight resolution before apply.
|
||||
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
|
||||
- Apply path is one-way for migrated plaintext values.
|
||||
- Apply path is one-way for scrubbed plaintext values.
|
||||
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
|
||||
- With `--apply` (and no `--yes`), CLI prompts an extra irreversible-migration confirmation.
|
||||
- With `--apply` (and no `--yes`), CLI prompts an extra irreversible confirmation.
|
||||
|
||||
Exec provider safety note:
|
||||
|
||||
- Homebrew installs often expose symlinked binaries under `/opt/homebrew/bin/*`.
|
||||
- Set `allowSymlinkCommand: true` only when needed for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
|
||||
- On Windows, if ACL verification is unavailable for a provider path, OpenClaw fails closed. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
|
||||
## Apply a saved plan
|
||||
|
||||
@@ -154,10 +160,9 @@ Safety comes from strict preflight + atomic-ish apply with best-effort in-memory
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Audit first, then configure, then confirm clean:
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets configure
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
|
||||
If `audit --check` still reports plaintext findings after a partial migration, verify you also migrated skill keys (`skills.entries.*.apiKey`) and any other reported target paths.
|
||||
If `audit --check` still reports plaintext findings, update the remaining reported target paths and rerun audit.
|
||||
|
||||
@@ -994,6 +994,7 @@
|
||||
"brave-search",
|
||||
"perplexity",
|
||||
"tools/diffs",
|
||||
"tools/pdf",
|
||||
"tools/elevated",
|
||||
"tools/exec",
|
||||
"tools/exec-approvals",
|
||||
@@ -1321,6 +1322,7 @@
|
||||
"pages": [
|
||||
"reference/wizard",
|
||||
"reference/token-use",
|
||||
"reference/secretref-credential-surface",
|
||||
"reference/prompt-caching",
|
||||
"reference/api-usage-costs",
|
||||
"reference/transcript-hygiene",
|
||||
|
||||
@@ -205,6 +205,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
|
||||
- Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for the default account.
|
||||
- Optional `channels.telegram.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
- In multi-account setups (2+ account ids), set an explicit default (`channels.telegram.defaultAccount` or `channels.telegram.accounts.default`) to avoid fallback routing; `openclaw doctor` warns when this is missing or invalid.
|
||||
- `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
|
||||
- Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
|
||||
- Retry policy: see [Retry policy](/concepts/retry).
|
||||
@@ -443,6 +444,13 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`.
|
||||
dmPolicy: "pairing",
|
||||
chatmode: "oncall", // oncall | onmessage | onchar
|
||||
oncharPrefixes: [">", "!"],
|
||||
commands: {
|
||||
native: true, // opt-in
|
||||
nativeSkills: true,
|
||||
callbackPath: "/api/channels/mattermost/command",
|
||||
// Optional explicit URL for reverse-proxy/public deployments
|
||||
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
|
||||
},
|
||||
textChunkLimit: 4000,
|
||||
chunkMode: "length",
|
||||
},
|
||||
@@ -452,6 +460,13 @@ Mattermost ships as a plugin: `openclaw plugins install @openclaw/mattermost`.
|
||||
|
||||
Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with trigger prefix).
|
||||
|
||||
When Mattermost native commands are enabled:
|
||||
|
||||
- `commands.callbackPath` must be a path (for example `/api/channels/mattermost/command`), not a full URL.
|
||||
- `commands.callbackUrl` must resolve to the OpenClaw gateway endpoint and be reachable from the Mattermost server.
|
||||
- For private/tailnet/internal callback hosts, Mattermost may require
|
||||
`ServiceSettings.AllowedUntrustedInternalConnections` to include the callback host/domain.
|
||||
Use host/domain values, not full URLs.
|
||||
- `channels.mattermost.configWrites`: allow or deny Mattermost-initiated config writes.
|
||||
- `channels.mattermost.requireMention`: require `@mention` before replying in channels.
|
||||
- Optional `channels.mattermost.defaultAccount` overrides default account selection when it matches a configured account id.
|
||||
@@ -1170,8 +1185,8 @@ Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/gateway
|
||||
|
||||
**`docker.binds`** mounts additional host directories; global and per-agent binds are merged.
|
||||
|
||||
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in main config.
|
||||
noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived token URL that serves a local bootstrap page; noVNC password is passed via URL fragment (instead of URL query).
|
||||
**Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt. Does not require `browser.enabled` in `openclaw.json`.
|
||||
noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived token URL (instead of exposing the password in the shared URL).
|
||||
|
||||
- `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser.
|
||||
- `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity.
|
||||
@@ -1605,7 +1620,8 @@ Defaults for Talk mode (macOS/iOS/Android).
|
||||
```
|
||||
|
||||
- Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID`.
|
||||
- `apiKey` falls back to `ELEVENLABS_API_KEY`.
|
||||
- `apiKey` and `providers.*.apiKey` accept plaintext strings or SecretRef objects.
|
||||
- `ELEVENLABS_API_KEY` fallback applies only when no Talk API key is configured.
|
||||
- `voiceAliases` lets Talk directives use friendly names.
|
||||
|
||||
---
|
||||
@@ -1804,7 +1820,7 @@ Configures inbound media understanding (image/audio/video):
|
||||
|
||||
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc.)
|
||||
- `model`: model id override
|
||||
- `profile` / `preferredProfile`: auth profile selection
|
||||
- `profile` / `preferredProfile`: `auth-profiles.json` profile selection
|
||||
|
||||
**CLI entry** (`type: "cli"`):
|
||||
|
||||
@@ -1817,7 +1833,7 @@ Configures inbound media understanding (image/audio/video):
|
||||
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
|
||||
- Failures fall back to the next entry.
|
||||
|
||||
Provider auth follows standard order: auth profiles → env vars → `models.providers.*.apiKey`.
|
||||
Provider auth follows standard order: `auth-profiles.json` → env vars → `models.providers.*.apiKey`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -2638,14 +2654,11 @@ Validation:
|
||||
- `source: "file"` id: absolute JSON pointer (for example `"/providers/openai/apiKey"`)
|
||||
- `source: "exec"` id pattern: `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$`
|
||||
|
||||
### Supported fields in config
|
||||
### Supported credential surface
|
||||
|
||||
- `models.providers.<provider>.apiKey`
|
||||
- `skills.entries.<skillKey>.apiKey`
|
||||
- `channels.googlechat.serviceAccount`
|
||||
- `channels.googlechat.serviceAccountRef`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccount`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
|
||||
- Canonical matrix: [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
- `secrets apply` targets supported `openclaw.json` credential paths.
|
||||
- `auth-profiles.json` refs are included in runtime resolution and audit coverage.
|
||||
|
||||
### Secret providers config
|
||||
|
||||
@@ -2683,6 +2696,7 @@ Notes:
|
||||
- If `trustedDirs` is configured, the trusted-dir check applies to the resolved target path.
|
||||
- `exec` child environment is minimal by default; pass required variables explicitly with `passEnv`.
|
||||
- Secret refs are resolved at activation time into an in-memory snapshot, then request paths read the snapshot only.
|
||||
- Active-surface filtering applies during activation: unresolved refs on enabled surfaces fail startup/reload, while inactive surfaces are skipped with diagnostics.
|
||||
|
||||
---
|
||||
|
||||
@@ -2702,8 +2716,8 @@ Notes:
|
||||
}
|
||||
```
|
||||
|
||||
- Per-agent auth profiles stored at `<agentDir>/auth-profiles.json`.
|
||||
- Auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
|
||||
- Per-agent profiles are stored at `<agentDir>/auth-profiles.json`.
|
||||
- `auth-profiles.json` supports value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
|
||||
- Static runtime credentials come from in-memory resolved snapshots; legacy static `auth.json` entries are scrubbed when discovered.
|
||||
- Legacy OAuth imports from `~/.openclaw/credentials/oauth.json`.
|
||||
- See [OAuth](/concepts/oauth).
|
||||
@@ -2900,7 +2914,7 @@ Split config into multiple files:
|
||||
- Array of files: deep-merged in order (later overrides earlier).
|
||||
- Sibling keys: merged after includes (override included values).
|
||||
- Nested includes: up to 10 levels deep.
|
||||
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of the main config file). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
|
||||
- Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of `openclaw.json`). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
|
||||
- Errors: clear messages for missing files, parse errors, and circular includes.
|
||||
|
||||
---
|
||||
|
||||
@@ -532,6 +532,7 @@ Rules:
|
||||
```
|
||||
|
||||
SecretRef details (including `secrets.providers` for `env`/`file`/`exec`) are in [Secrets Management](/gateway/secrets).
|
||||
Supported credential paths are listed in [SecretRef Credential Surface](/reference/secretref-credential-surface).
|
||||
</Accordion>
|
||||
|
||||
See [Environment](/help/environment) for full precedence and sources.
|
||||
|
||||
@@ -128,6 +128,11 @@ Current migrations:
|
||||
→ `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks`
|
||||
- `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork`
|
||||
|
||||
Doctor warnings also include account-default guidance for multi-account channels:
|
||||
|
||||
- If two or more `channels.<channel>.accounts` entries are configured without `channels.<channel>.defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account.
|
||||
- If `channels.<channel>.defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs.
|
||||
|
||||
### 2b) OpenCode Zen provider overrides
|
||||
|
||||
If you’ve added `models.providers.opencode` (or `opencode-zen`) manually, it
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
summary: "Contract for `secrets apply` plans: allowed target paths, validation, and ref-only auth-profile behavior"
|
||||
summary: "Contract for `secrets apply` plans: target validation, path matching, and `auth-profiles.json` target scope"
|
||||
read_when:
|
||||
- Generating or reviewing `openclaw secrets apply` plan files
|
||||
- Generating or reviewing `openclaw secrets apply` plans
|
||||
- Debugging `Invalid plan target path` errors
|
||||
- Understanding how `keyRef` and `tokenRef` influence implicit provider discovery
|
||||
- Understanding target type and path validation behavior
|
||||
title: "Secrets Apply Plan Contract"
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ title: "Secrets Apply Plan Contract"
|
||||
|
||||
This page defines the strict contract enforced by `openclaw secrets apply`.
|
||||
|
||||
If a target does not match these rules, apply fails before mutating config.
|
||||
If a target does not match these rules, apply fails before mutating configuration.
|
||||
|
||||
## Plan file shape
|
||||
|
||||
@@ -29,29 +29,47 @@ If a target does not match these rules, apply fails before mutating config.
|
||||
providerId: "openai",
|
||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
{
|
||||
type: "auth-profiles.api_key.key",
|
||||
path: "profiles.openai:default.key",
|
||||
pathSegments: ["profiles", "openai:default", "key"],
|
||||
agentId: "main",
|
||||
ref: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Allowed target types and paths
|
||||
## Supported target scope
|
||||
|
||||
| `target.type` | Allowed `target.path` shape | Optional id match rule |
|
||||
| ------------------------------------ | --------------------------------------------------------- | --------------------------------------------------- |
|
||||
| `models.providers.apiKey` | `models.providers.<providerId>.apiKey` | `providerId` must match `<providerId>` when present |
|
||||
| `skills.entries.apiKey` | `skills.entries.<skillKey>.apiKey` | n/a |
|
||||
| `channels.googlechat.serviceAccount` | `channels.googlechat.serviceAccount` | `accountId` must be empty/omitted |
|
||||
| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts.<accountId>.serviceAccount` | `accountId` must match `<accountId>` when present |
|
||||
Plan targets are accepted for supported credential paths in:
|
||||
|
||||
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
|
||||
## Target type behavior
|
||||
|
||||
General rule:
|
||||
|
||||
- `target.type` must be recognized and must match the normalized `target.path` shape.
|
||||
|
||||
Compatibility aliases remain accepted for existing plans:
|
||||
|
||||
- `models.providers.apiKey`
|
||||
- `skills.entries.apiKey`
|
||||
- `channels.googlechat.serviceAccount`
|
||||
|
||||
## Path validation rules
|
||||
|
||||
Each target is validated with all of the following:
|
||||
|
||||
- `type` must be one of the allowed target types above.
|
||||
- `type` must be a recognized target type.
|
||||
- `path` must be a non-empty dot path.
|
||||
- `pathSegments` can be omitted. If provided, it must normalize to exactly the same path as `path`.
|
||||
- Forbidden segments are rejected: `__proto__`, `prototype`, `constructor`.
|
||||
- The normalized path must match one of the allowed path shapes for the target type.
|
||||
- If `providerId` / `accountId` is set, it must match the id encoded in the path.
|
||||
- The normalized path must match the registered path shape for the target type.
|
||||
- If `providerId` or `accountId` is set, it must match the id encoded in the path.
|
||||
- `auth-profiles.json` targets require `agentId`.
|
||||
- When creating a new `auth-profiles.json` mapping, include `authProfileProvider`.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
@@ -61,19 +79,12 @@ If a target fails validation, apply exits with an error like:
|
||||
Invalid plan target path for models.providers.apiKey: models.providers.openai.baseUrl
|
||||
```
|
||||
|
||||
No partial mutation is committed for that invalid target path.
|
||||
No writes are committed for an invalid plan.
|
||||
|
||||
## Ref-only auth profiles and implicit providers
|
||||
## Runtime and audit scope notes
|
||||
|
||||
Implicit provider discovery also considers auth profiles that store refs instead of plaintext credentials:
|
||||
|
||||
- `type: "api_key"` profiles can use `keyRef` (for example env-backed refs).
|
||||
- `type: "token"` profiles can use `tokenRef`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- For API-key providers (for example `volcengine`, `byteplus`), ref-only profiles can still activate implicit provider entries.
|
||||
- For `github-copilot`, if the profile has no plaintext token, discovery will try `tokenRef` env resolution before token exchange.
|
||||
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage.
|
||||
- `secrets apply` writes supported `openclaw.json` targets, supported `auth-profiles.json` targets, and optional scrub targets.
|
||||
|
||||
## Operator checks
|
||||
|
||||
@@ -85,10 +96,11 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
```
|
||||
|
||||
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to one of the allowed shapes above.
|
||||
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [Secrets Management](/gateway/secrets)
|
||||
- [CLI `secrets`](/cli/secrets)
|
||||
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
- [Configuration Reference](/gateway/configuration-reference)
|
||||
|
||||
@@ -1,35 +1,70 @@
|
||||
---
|
||||
summary: "Secrets management: SecretRef contract, runtime snapshot behavior, and safe one-way scrubbing"
|
||||
read_when:
|
||||
- Configuring SecretRefs for providers, auth profiles, skills, or Google Chat
|
||||
- Operating secrets reload/audit/configure/apply safely in production
|
||||
- Understanding fail-fast and last-known-good behavior
|
||||
- Configuring SecretRefs for provider credentials and `auth-profiles.json` refs
|
||||
- Operating secrets reload, audit, configure, and apply safely in production
|
||||
- Understanding startup fail-fast, inactive-surface filtering, and last-known-good behavior
|
||||
title: "Secrets Management"
|
||||
---
|
||||
|
||||
# Secrets management
|
||||
|
||||
OpenClaw supports additive secret references so credentials do not need to be stored as plaintext in config files.
|
||||
OpenClaw supports additive SecretRefs so supported credentials do not need to be stored as plaintext in configuration.
|
||||
|
||||
Plaintext still works. Secret refs are optional.
|
||||
Plaintext still works. SecretRefs are opt-in per credential.
|
||||
|
||||
## Goals and runtime model
|
||||
|
||||
Secrets are resolved into an in-memory runtime snapshot.
|
||||
|
||||
- Resolution is eager during activation, not lazy on request paths.
|
||||
- Startup fails fast if any referenced credential cannot be resolved.
|
||||
- Reload uses atomic swap: full success or keep last-known-good.
|
||||
- Runtime requests read from the active in-memory snapshot.
|
||||
- Startup fails fast when an effectively active SecretRef cannot be resolved.
|
||||
- Reload uses atomic swap: full success, or keep the last-known-good snapshot.
|
||||
- Runtime requests read from the active in-memory snapshot only.
|
||||
|
||||
This keeps secret-provider outages off the hot request path.
|
||||
This keeps secret-provider outages off hot request paths.
|
||||
|
||||
## Active-surface filtering
|
||||
|
||||
SecretRefs are validated only on effectively active surfaces.
|
||||
|
||||
- Enabled surfaces: unresolved refs block startup/reload.
|
||||
- Inactive surfaces: unresolved refs do not block startup/reload.
|
||||
- Inactive refs emit non-fatal diagnostics with code `SECRETS_REF_IGNORED_INACTIVE_SURFACE`.
|
||||
|
||||
Examples of inactive surfaces:
|
||||
|
||||
- Disabled channel/account entries.
|
||||
- Top-level channel credentials that no enabled account inherits.
|
||||
- Disabled tool/feature surfaces.
|
||||
- Web search provider-specific keys that are not selected by `tools.web.search.provider`.
|
||||
In auto mode (provider unset), provider-specific keys are also active for provider auto-detection.
|
||||
- `gateway.remote.token` / `gateway.remote.password` SecretRefs are active (when `gateway.remote.enabled` is not `false`) if one of these is true:
|
||||
- `gateway.mode=remote`
|
||||
- `gateway.remote.url` is configured
|
||||
- `gateway.tailscale.mode` is `serve` or `funnel`
|
||||
In local mode without those remote surfaces:
|
||||
- `gateway.remote.token` is active when token auth can win and no env/auth token is configured.
|
||||
- `gateway.remote.password` is active only when password auth can win and no env/auth password is configured.
|
||||
|
||||
## Gateway auth surface diagnostics
|
||||
|
||||
When a SecretRef is configured on `gateway.auth.password`, `gateway.remote.token`, or
|
||||
`gateway.remote.password`, gateway startup/reload logs the surface state explicitly:
|
||||
|
||||
- `active`: the SecretRef is part of the effective auth surface and must resolve.
|
||||
- `inactive`: the SecretRef is ignored for this runtime because another auth surface wins, or
|
||||
because remote auth is disabled/not active.
|
||||
|
||||
These entries are logged with `SECRETS_GATEWAY_AUTH_SURFACE` and include the reason used by the
|
||||
active-surface policy, so you can see why a credential was treated as active or inactive.
|
||||
|
||||
## Onboarding reference preflight
|
||||
|
||||
When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving:
|
||||
When onboarding runs in interactive mode and you choose SecretRef storage, OpenClaw runs preflight validation before saving:
|
||||
|
||||
- Env refs: validates env var name and confirms a non-empty value is visible during onboarding.
|
||||
- Provider refs (`file` or `exec`): validates the selected provider, resolves the provided `id`, and checks value type.
|
||||
- Provider refs (`file` or `exec`): validates provider selection, resolves `id`, and checks resolved value type.
|
||||
|
||||
If validation fails, onboarding shows the error and lets you retry.
|
||||
|
||||
@@ -122,22 +157,24 @@ Define providers under `secrets.providers`:
|
||||
- `mode: "json"` expects JSON object payload and resolves `id` as pointer.
|
||||
- `mode: "singleValue"` expects ref id `"value"` and returns file contents.
|
||||
- Path must pass ownership/permission checks.
|
||||
- Windows fail-closed note: if ACL verification is unavailable for a path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
|
||||
### Exec provider
|
||||
|
||||
- Runs configured absolute binary path, no shell.
|
||||
- By default, `command` must point to a regular file (not a symlink).
|
||||
- Set `allowSymlinkCommand: true` to allow symlink command paths (for example Homebrew shims). OpenClaw validates the resolved target path.
|
||||
- Enable `allowSymlinkCommand` only when required for trusted package-manager paths, and pair it with `trustedDirs` (for example `["/opt/homebrew"]`).
|
||||
- When `trustedDirs` is set, checks apply to the resolved target path.
|
||||
- Pair `allowSymlinkCommand` with `trustedDirs` for package-manager paths (for example `["/opt/homebrew"]`).
|
||||
- Supports timeout, no-output timeout, output byte limits, env allowlist, and trusted dirs.
|
||||
- Request payload (stdin):
|
||||
- Windows fail-closed note: if ACL verification is unavailable for the command path, resolution fails. For trusted paths only, set `allowInsecurePath: true` on that provider to bypass path security checks.
|
||||
|
||||
Request payload (stdin):
|
||||
|
||||
```json
|
||||
{ "protocolVersion": 1, "provider": "vault", "ids": ["providers/openai/apiKey"] }
|
||||
```
|
||||
|
||||
- Response payload (stdout):
|
||||
Response payload (stdout):
|
||||
|
||||
```json
|
||||
{ "protocolVersion": 1, "values": { "providers/openai/apiKey": "sk-..." } }
|
||||
@@ -242,37 +279,33 @@ Optional per-id errors:
|
||||
}
|
||||
```
|
||||
|
||||
## In-scope fields (v1)
|
||||
## Supported credential surface
|
||||
|
||||
### `~/.openclaw/openclaw.json`
|
||||
Canonical supported and unsupported credentials are listed in:
|
||||
|
||||
- `models.providers.<provider>.apiKey`
|
||||
- `skills.entries.<skillKey>.apiKey`
|
||||
- `channels.googlechat.serviceAccount`
|
||||
- `channels.googlechat.serviceAccountRef`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccount`
|
||||
- `channels.googlechat.accounts.<accountId>.serviceAccountRef`
|
||||
- [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
|
||||
### `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`
|
||||
|
||||
- `profiles.<profileId>.keyRef` for `type: "api_key"`
|
||||
- `profiles.<profileId>.tokenRef` for `type: "token"`
|
||||
|
||||
OAuth credential storage changes are out of scope.
|
||||
Runtime-minted or rotating credentials and OAuth refresh material are intentionally excluded from read-only SecretRef resolution.
|
||||
|
||||
## Required behavior and precedence
|
||||
|
||||
- Field without ref: unchanged.
|
||||
- Field with ref: required at activation time.
|
||||
- If plaintext and ref both exist, ref wins at runtime and plaintext is ignored.
|
||||
- Field without a ref: unchanged.
|
||||
- Field with a ref: required on active surfaces during activation.
|
||||
- If both plaintext and ref are present, ref takes precedence on supported precedence paths.
|
||||
|
||||
Warning code:
|
||||
Warning and audit signals:
|
||||
|
||||
- `SECRETS_REF_OVERRIDES_PLAINTEXT`
|
||||
- `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning)
|
||||
- `REF_SHADOWED` (audit finding when `auth-profiles.json` credentials take precedence over `openclaw.json` refs)
|
||||
|
||||
Google Chat compatibility behavior:
|
||||
|
||||
- `serviceAccountRef` takes precedence over plaintext `serviceAccount`.
|
||||
- Plaintext value is ignored when sibling ref is set.
|
||||
|
||||
## Activation triggers
|
||||
|
||||
Secret activation is attempted on:
|
||||
Secret activation runs on:
|
||||
|
||||
- Startup (preflight plus final activation)
|
||||
- Config reload hot-apply path
|
||||
@@ -283,9 +316,9 @@ Activation contract:
|
||||
|
||||
- Success swaps the snapshot atomically.
|
||||
- Startup failure aborts gateway startup.
|
||||
- Runtime reload failure keeps last-known-good snapshot.
|
||||
- Runtime reload failure keeps the last-known-good snapshot.
|
||||
|
||||
## Degraded and recovered operator signals
|
||||
## Degraded and recovered signals
|
||||
|
||||
When reload-time activation fails after a healthy state, OpenClaw enters degraded secrets state.
|
||||
|
||||
@@ -297,13 +330,22 @@ One-shot system event and log codes:
|
||||
Behavior:
|
||||
|
||||
- Degraded: runtime keeps last-known-good snapshot.
|
||||
- Recovered: emitted once after a successful activation.
|
||||
- Recovered: emitted once after the next successful activation.
|
||||
- Repeated failures while already degraded log warnings but do not spam events.
|
||||
- Startup fail-fast does not emit degraded events because no runtime snapshot exists yet.
|
||||
- Startup fail-fast does not emit degraded events because runtime never became active.
|
||||
|
||||
## Command-path resolution
|
||||
|
||||
Credential-sensitive command paths that opt in (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) can resolve supported SecretRefs via gateway snapshot RPC.
|
||||
|
||||
- When gateway is running, those command paths read from the active snapshot.
|
||||
- If a configured SecretRef is required and gateway is unavailable, command resolution fails fast with actionable diagnostics.
|
||||
- Snapshot refresh after backend secret rotation is handled by `openclaw secrets reload`.
|
||||
- Gateway RPC method used by these command paths: `secrets.resolve`.
|
||||
|
||||
## Audit and configure workflow
|
||||
|
||||
Use this default operator flow:
|
||||
Default operator flow:
|
||||
|
||||
```bash
|
||||
openclaw secrets audit --check
|
||||
@@ -311,26 +353,22 @@ openclaw secrets configure
|
||||
openclaw secrets audit --check
|
||||
```
|
||||
|
||||
Migration completeness:
|
||||
|
||||
- Include `skills.entries.<skillKey>.apiKey` targets when those skills use API keys.
|
||||
- If `audit --check` still reports plaintext findings after a partial migration, migrate the remaining reported paths and rerun audit.
|
||||
|
||||
### `secrets audit`
|
||||
|
||||
Findings include:
|
||||
|
||||
- plaintext values at rest (`openclaw.json`, `auth-profiles.json`, `.env`)
|
||||
- unresolved refs
|
||||
- precedence shadowing (`auth-profiles` taking priority over config refs)
|
||||
- legacy residues (`auth.json`, OAuth out-of-scope reminders)
|
||||
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
|
||||
- legacy residues (`auth.json`, OAuth reminders)
|
||||
|
||||
### `secrets configure`
|
||||
|
||||
Interactive helper that:
|
||||
|
||||
- configures `secrets.providers` first (`env`/`file`/`exec`, add/edit/remove)
|
||||
- lets you select secret-bearing fields in `openclaw.json`
|
||||
- lets you select supported secret-bearing fields in `openclaw.json` plus `auth-profiles.json` for one agent scope
|
||||
- can create a new `auth-profiles.json` mapping directly in the target picker
|
||||
- captures SecretRef details (`source`, `provider`, `id`)
|
||||
- runs preflight resolution
|
||||
- can apply immediately
|
||||
@@ -339,10 +377,11 @@ Helpful modes:
|
||||
|
||||
- `openclaw secrets configure --providers-only`
|
||||
- `openclaw secrets configure --skip-provider-setup`
|
||||
- `openclaw secrets configure --agent <id>`
|
||||
|
||||
`configure` apply defaults to:
|
||||
`configure` apply defaults:
|
||||
|
||||
- scrub matching static creds from `auth-profiles.json` for targeted providers
|
||||
- scrub matching static credentials from `auth-profiles.json` for targeted providers
|
||||
- scrub legacy static `api_key` entries from `auth.json`
|
||||
- scrub matching known secret lines from `<config-dir>/.env`
|
||||
|
||||
@@ -361,26 +400,31 @@ For strict target/path contract details and exact rejection rules, see:
|
||||
|
||||
## One-way safety policy
|
||||
|
||||
OpenClaw intentionally does **not** write rollback backups that contain pre-migration plaintext secret values.
|
||||
OpenClaw intentionally does not write rollback backups containing historical plaintext secret values.
|
||||
|
||||
Safety model:
|
||||
|
||||
- preflight must succeed before write mode
|
||||
- runtime activation is validated before commit
|
||||
- apply updates files using atomic file replacement and best-effort in-memory restore on failure
|
||||
- apply updates files using atomic file replacement and best-effort restore on failure
|
||||
|
||||
## `auth.json` compatibility notes
|
||||
## Legacy auth compatibility notes
|
||||
|
||||
For static credentials, OpenClaw runtime no longer depends on plaintext `auth.json`.
|
||||
For static credentials, runtime no longer depends on plaintext legacy auth storage.
|
||||
|
||||
- Runtime credential source is the resolved in-memory snapshot.
|
||||
- Legacy `auth.json` static `api_key` entries are scrubbed when discovered.
|
||||
- OAuth-related legacy compatibility behavior remains separate.
|
||||
- Legacy static `api_key` entries are scrubbed when discovered.
|
||||
- OAuth-related compatibility behavior remains separate.
|
||||
|
||||
## Web UI note
|
||||
|
||||
Some SecretInput unions are easier to configure in raw editor mode than in form mode.
|
||||
|
||||
## Related docs
|
||||
|
||||
- CLI commands: [secrets](/cli/secrets)
|
||||
- Plan contract details: [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
- Credential surface: [SecretRef Credential Surface](/reference/secretref-credential-surface)
|
||||
- Auth setup: [Authentication](/gateway/authentication)
|
||||
- Security posture: [Security](/gateway/security)
|
||||
- Environment precedence: [Environment Variables](/help/environment)
|
||||
|
||||
BIN
docs/images/feishu-verification-token.png
Normal file
BIN
docs/images/feishu-verification-token.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@@ -82,12 +82,6 @@ See [Memory](/concepts/memory).
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
**Brave free tier (generous):**
|
||||
|
||||
- **2,000 requests/month**
|
||||
- **1 request/second**
|
||||
- **Credit card required** for verification (no charge unless you upgrade)
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
### 5) Web fetch tool (Firecrawl)
|
||||
|
||||
123
docs/reference/secretref-credential-surface.md
Normal file
123
docs/reference/secretref-credential-surface.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
summary: "Canonical supported vs unsupported SecretRef credential surface"
|
||||
read_when:
|
||||
- Verifying SecretRef credential coverage
|
||||
- Auditing whether a credential is eligible for `secrets configure` or `secrets apply`
|
||||
- Verifying why a credential is outside the supported surface
|
||||
title: "SecretRef Credential Surface"
|
||||
---
|
||||
|
||||
# SecretRef credential surface
|
||||
|
||||
This page defines the canonical SecretRef credential surface.
|
||||
|
||||
Scope intent:
|
||||
|
||||
- In scope: strictly user-supplied credentials that OpenClaw does not mint or rotate.
|
||||
- Out of scope: runtime-minted or rotating credentials, OAuth refresh material, and session-like artifacts.
|
||||
|
||||
## Supported credentials
|
||||
|
||||
### `openclaw.json` targets (`secrets configure` + `secrets apply` + `secrets audit`)
|
||||
|
||||
<!-- secretref-supported-list-start -->
|
||||
|
||||
- `models.providers.*.apiKey`
|
||||
- `skills.entries.*.apiKey`
|
||||
- `agents.defaults.memorySearch.remote.apiKey`
|
||||
- `agents.list[].memorySearch.remote.apiKey`
|
||||
- `talk.apiKey`
|
||||
- `talk.providers.*.apiKey`
|
||||
- `messages.tts.elevenlabs.apiKey`
|
||||
- `messages.tts.openai.apiKey`
|
||||
- `tools.web.search.apiKey`
|
||||
- `tools.web.search.gemini.apiKey`
|
||||
- `tools.web.search.grok.apiKey`
|
||||
- `tools.web.search.kimi.apiKey`
|
||||
- `tools.web.search.perplexity.apiKey`
|
||||
- `gateway.auth.password`
|
||||
- `gateway.remote.token`
|
||||
- `gateway.remote.password`
|
||||
- `cron.webhookToken`
|
||||
- `channels.telegram.botToken`
|
||||
- `channels.telegram.webhookSecret`
|
||||
- `channels.telegram.accounts.*.botToken`
|
||||
- `channels.telegram.accounts.*.webhookSecret`
|
||||
- `channels.slack.botToken`
|
||||
- `channels.slack.appToken`
|
||||
- `channels.slack.userToken`
|
||||
- `channels.slack.signingSecret`
|
||||
- `channels.slack.accounts.*.botToken`
|
||||
- `channels.slack.accounts.*.appToken`
|
||||
- `channels.slack.accounts.*.userToken`
|
||||
- `channels.slack.accounts.*.signingSecret`
|
||||
- `channels.discord.token`
|
||||
- `channels.discord.pluralkit.token`
|
||||
- `channels.discord.voice.tts.elevenlabs.apiKey`
|
||||
- `channels.discord.voice.tts.openai.apiKey`
|
||||
- `channels.discord.accounts.*.token`
|
||||
- `channels.discord.accounts.*.pluralkit.token`
|
||||
- `channels.discord.accounts.*.voice.tts.elevenlabs.apiKey`
|
||||
- `channels.discord.accounts.*.voice.tts.openai.apiKey`
|
||||
- `channels.irc.password`
|
||||
- `channels.irc.nickserv.password`
|
||||
- `channels.irc.accounts.*.password`
|
||||
- `channels.irc.accounts.*.nickserv.password`
|
||||
- `channels.bluebubbles.password`
|
||||
- `channels.bluebubbles.accounts.*.password`
|
||||
- `channels.feishu.appSecret`
|
||||
- `channels.feishu.verificationToken`
|
||||
- `channels.feishu.accounts.*.appSecret`
|
||||
- `channels.feishu.accounts.*.verificationToken`
|
||||
- `channels.msteams.appPassword`
|
||||
- `channels.mattermost.botToken`
|
||||
- `channels.mattermost.accounts.*.botToken`
|
||||
- `channels.matrix.password`
|
||||
- `channels.matrix.accounts.*.password`
|
||||
- `channels.nextcloud-talk.botSecret`
|
||||
- `channels.nextcloud-talk.apiPassword`
|
||||
- `channels.nextcloud-talk.accounts.*.botSecret`
|
||||
- `channels.nextcloud-talk.accounts.*.apiPassword`
|
||||
- `channels.zalo.botToken`
|
||||
- `channels.zalo.webhookSecret`
|
||||
- `channels.zalo.accounts.*.botToken`
|
||||
- `channels.zalo.accounts.*.webhookSecret`
|
||||
- `channels.googlechat.serviceAccount` via sibling `serviceAccountRef` (compatibility exception)
|
||||
- `channels.googlechat.accounts.*.serviceAccount` via sibling `serviceAccountRef` (compatibility exception)
|
||||
|
||||
### `auth-profiles.json` targets (`secrets configure` + `secrets apply` + `secrets audit`)
|
||||
|
||||
- `profiles.*.keyRef` (`type: "api_key"`)
|
||||
- `profiles.*.tokenRef` (`type: "token"`)
|
||||
<!-- secretref-supported-list-end -->
|
||||
|
||||
Notes:
|
||||
|
||||
- Auth-profile plan targets require `agentId`.
|
||||
- Plan entries target `profiles.*.key` / `profiles.*.token` and write sibling refs (`keyRef` / `tokenRef`).
|
||||
- Auth-profile refs are included in runtime resolution and audit coverage.
|
||||
- For web search:
|
||||
- In explicit provider mode (`tools.web.search.provider` set), only the selected provider key is active.
|
||||
- In auto mode (`tools.web.search.provider` unset), `tools.web.search.apiKey` and provider-specific keys are active.
|
||||
|
||||
## Unsupported credentials
|
||||
|
||||
Out-of-scope credentials include:
|
||||
|
||||
<!-- secretref-unsupported-list-start -->
|
||||
|
||||
- `gateway.auth.token`
|
||||
- `commands.ownerDisplaySecret`
|
||||
- `channels.matrix.accessToken`
|
||||
- `channels.matrix.accounts.*.accessToken`
|
||||
- `hooks.token`
|
||||
- `hooks.gmail.pushToken`
|
||||
- `hooks.mappings[].sessionKey`
|
||||
- `auth-profiles.oauth.*`
|
||||
- `discord.threadBindings.*.webhookToken`
|
||||
- `whatsapp.creds.json`
|
||||
<!-- secretref-unsupported-list-end -->
|
||||
|
||||
Rationale:
|
||||
|
||||
- These credentials are minted, rotated, session-bearing, or OAuth-durable classes that do not fit read-only external SecretRef resolution.
|
||||
480
docs/reference/secretref-user-supplied-credentials-matrix.json
Normal file
480
docs/reference/secretref-user-supplied-credentials-matrix.json
Normal file
@@ -0,0 +1,480 @@
|
||||
{
|
||||
"version": 1,
|
||||
"matrixId": "strictly-user-supplied-credentials",
|
||||
"pathSyntax": "Dot path with \"*\" for map keys and \"[]\" for arrays.",
|
||||
"scope": "Credentials that are strictly user-supplied and not minted/rotated by OpenClaw runtime.",
|
||||
"excludedMutableOrRuntimeManaged": [
|
||||
"commands.ownerDisplaySecret",
|
||||
"channels.matrix.accessToken",
|
||||
"channels.matrix.accounts.*.accessToken",
|
||||
"gateway.auth.token",
|
||||
"hooks.token",
|
||||
"hooks.gmail.pushToken",
|
||||
"hooks.mappings[].sessionKey",
|
||||
"auth-profiles.oauth.*",
|
||||
"discord.threadBindings.*.webhookToken",
|
||||
"whatsapp.creds.json"
|
||||
],
|
||||
"entries": [
|
||||
{
|
||||
"id": "agents.defaults.memorySearch.remote.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "agents.defaults.memorySearch.remote.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].memorySearch.remote.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "agents.list[].memorySearch.remote.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "auth-profiles.api_key.key",
|
||||
"configFile": "auth-profiles.json",
|
||||
"path": "profiles.*.key",
|
||||
"refPath": "profiles.*.keyRef",
|
||||
"when": {
|
||||
"type": "api_key"
|
||||
},
|
||||
"secretShape": "sibling_ref",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "auth-profiles.token.token",
|
||||
"configFile": "auth-profiles.json",
|
||||
"path": "profiles.*.token",
|
||||
"refPath": "profiles.*.tokenRef",
|
||||
"when": {
|
||||
"type": "token"
|
||||
},
|
||||
"secretShape": "sibling_ref",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.bluebubbles.accounts.*.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.bluebubbles.accounts.*.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.bluebubbles.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.bluebubbles.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.accounts.*.pluralkit.token",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.accounts.*.pluralkit.token",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.accounts.*.token",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.accounts.*.token",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.accounts.*.voice.tts.elevenlabs.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.accounts.*.voice.tts.openai.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.accounts.*.voice.tts.openai.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.pluralkit.token",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.pluralkit.token",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.token",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.token",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.voice.tts.elevenlabs.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.voice.tts.elevenlabs.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.discord.voice.tts.openai.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.discord.voice.tts.openai.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.feishu.accounts.*.appSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.feishu.accounts.*.appSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.feishu.accounts.*.verificationToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.feishu.accounts.*.verificationToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.feishu.appSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.feishu.appSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.feishu.verificationToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.feishu.verificationToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.googlechat.accounts.*.serviceAccount",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.googlechat.accounts.*.serviceAccount",
|
||||
"refPath": "channels.googlechat.accounts.*.serviceAccountRef",
|
||||
"secretShape": "sibling_ref",
|
||||
"optIn": true,
|
||||
"notes": "Google Chat compatibility exception: sibling ref field remains canonical."
|
||||
},
|
||||
{
|
||||
"id": "channels.googlechat.serviceAccount",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.googlechat.serviceAccount",
|
||||
"refPath": "channels.googlechat.serviceAccountRef",
|
||||
"secretShape": "sibling_ref",
|
||||
"optIn": true,
|
||||
"notes": "Google Chat compatibility exception: sibling ref field remains canonical."
|
||||
},
|
||||
{
|
||||
"id": "channels.irc.accounts.*.nickserv.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.irc.accounts.*.nickserv.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.irc.accounts.*.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.irc.accounts.*.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.irc.nickserv.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.irc.nickserv.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.irc.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.irc.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.matrix.accounts.*.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.matrix.accounts.*.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.matrix.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.matrix.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.mattermost.accounts.*.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.mattermost.accounts.*.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.mattermost.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.mattermost.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.msteams.appPassword",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.msteams.appPassword",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.nextcloud-talk.accounts.*.apiPassword",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.nextcloud-talk.accounts.*.apiPassword",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.nextcloud-talk.accounts.*.botSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.nextcloud-talk.accounts.*.botSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.nextcloud-talk.apiPassword",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.nextcloud-talk.apiPassword",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.nextcloud-talk.botSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.nextcloud-talk.botSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.appToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.accounts.*.appToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.accounts.*.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.signingSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.accounts.*.signingSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.accounts.*.userToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.accounts.*.userToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.appToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.appToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.signingSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.signingSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.slack.userToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.slack.userToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.telegram.accounts.*.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.telegram.accounts.*.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.telegram.accounts.*.webhookSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.telegram.accounts.*.webhookSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.telegram.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.telegram.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.telegram.webhookSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.telegram.webhookSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.zalo.accounts.*.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.zalo.accounts.*.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.zalo.accounts.*.webhookSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.zalo.accounts.*.webhookSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.zalo.botToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.zalo.botToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "channels.zalo.webhookSecret",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "channels.zalo.webhookSecret",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "cron.webhookToken",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "cron.webhookToken",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "gateway.auth.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "gateway.auth.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "gateway.remote.password",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "gateway.remote.password",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "gateway.remote.token",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "gateway.remote.token",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "messages.tts.elevenlabs.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "messages.tts.elevenlabs.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "messages.tts.openai.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "messages.tts.openai.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "models.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "models.providers.*.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "skills.entries.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "skills.entries.*.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "talk.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "talk.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "talk.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "talk.providers.*.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.search.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.gemini.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.search.gemini.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.grok.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.search.grok.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.kimi.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.search.kimi.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "tools.web.search.perplexity.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "tools.web.search.perplexity.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -109,6 +109,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [OpenProse](/prose)
|
||||
- [CLI reference](/cli)
|
||||
- [Exec tool](/tools/exec)
|
||||
- [PDF tool](/tools/pdf)
|
||||
- [Elevated mode](/tools/elevated)
|
||||
- [Cron jobs](/automation/cron-jobs)
|
||||
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat)
|
||||
|
||||
@@ -313,7 +313,7 @@ See [Configuration Reference](/gateway/configuration-reference).
|
||||
Install and enable plugin:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/acpx
|
||||
openclaw plugins install acpx
|
||||
openclaw config set plugins.entries.acpx.enabled true
|
||||
```
|
||||
|
||||
@@ -331,7 +331,7 @@ Then verify backend health:
|
||||
|
||||
### acpx command and version configuration
|
||||
|
||||
By default, `@openclaw/acpx` uses the plugin-local pinned binary:
|
||||
By default, the acpx plugin (published as `@openclaw/acpx`) uses the plugin-local pinned binary:
|
||||
|
||||
1. Command defaults to `extensions/acpx/node_modules/.bin/acpx`.
|
||||
2. Expected version defaults to the extension pin.
|
||||
|
||||
@@ -10,7 +10,7 @@ read_when:
|
||||
|
||||
# Diffs
|
||||
|
||||
`diffs` is an optional plugin tool that turns change content into a read-only diff artifact for agents.
|
||||
`diffs` is an optional plugin tool and companion skill that turns change content into a read-only diff artifact for agents.
|
||||
|
||||
It accepts either:
|
||||
|
||||
|
||||
@@ -401,21 +401,7 @@ Notes:
|
||||
|
||||
Analyze one or more PDF documents.
|
||||
|
||||
Core parameters:
|
||||
|
||||
- `pdf` (single path or URL)
|
||||
- `pdfs` (multiple paths or URLs, up to 10)
|
||||
- `prompt` (optional, defaults to "Analyze this PDF document.")
|
||||
- `pages` (optional page range like `1-5` or `1,3,7-9`)
|
||||
- `model` (optional model override)
|
||||
- `maxBytesMb` (optional size cap)
|
||||
|
||||
Notes:
|
||||
|
||||
- Native PDF provider mode is supported for Anthropic and Google models.
|
||||
- Non-native models use PDF extraction fallback, text first, then rasterized page images when needed.
|
||||
- `pages` filtering is only supported in extraction fallback mode. Native providers return a clear error when `pages` is set.
|
||||
- Defaults are configurable via `agents.defaults.pdfModel`, `agents.defaults.pdfMaxBytesMb`, and `agents.defaults.pdfMaxPages`.
|
||||
For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf).
|
||||
|
||||
### `message`
|
||||
|
||||
|
||||
156
docs/tools/pdf.md
Normal file
156
docs/tools/pdf.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
title: "PDF Tool"
|
||||
summary: "Analyze one or more PDF documents with native provider support and extraction fallback"
|
||||
read_when:
|
||||
- You want to analyze PDFs from agents
|
||||
- You need exact pdf tool parameters and limits
|
||||
- You are debugging native PDF mode vs extraction fallback
|
||||
---
|
||||
|
||||
# PDF tool
|
||||
|
||||
`pdf` analyzes one or more PDF documents and returns text.
|
||||
|
||||
Quick behavior:
|
||||
|
||||
- Native provider mode for Anthropic and Google model providers.
|
||||
- Extraction fallback mode for other providers (extract text first, then page images when needed).
|
||||
- Supports single (`pdf`) or multi (`pdfs`) input, max 10 PDFs per call.
|
||||
|
||||
## Availability
|
||||
|
||||
The tool is only registered when OpenClaw can resolve a PDF-capable model config for the agent:
|
||||
|
||||
1. `agents.defaults.pdfModel`
|
||||
2. fallback to `agents.defaults.imageModel`
|
||||
3. fallback to best effort provider defaults based on available auth
|
||||
|
||||
If no usable model can be resolved, the `pdf` tool is not exposed.
|
||||
|
||||
## Input reference
|
||||
|
||||
- `pdf` (`string`): one PDF path or URL
|
||||
- `pdfs` (`string[]`): multiple PDF paths or URLs, up to 10 total
|
||||
- `prompt` (`string`): analysis prompt, default `Analyze this PDF document.`
|
||||
- `pages` (`string`): page filter like `1-5` or `1,3,7-9`
|
||||
- `model` (`string`): optional model override (`provider/model`)
|
||||
- `maxBytesMb` (`number`): per-PDF size cap in MB
|
||||
|
||||
Input notes:
|
||||
|
||||
- `pdf` and `pdfs` are merged and deduplicated before loading.
|
||||
- If no PDF input is provided, the tool errors.
|
||||
- `pages` is parsed as 1-based page numbers, deduped, sorted, and clamped to the configured max pages.
|
||||
- `maxBytesMb` defaults to `agents.defaults.pdfMaxBytesMb` or `10`.
|
||||
|
||||
## Supported PDF references
|
||||
|
||||
- local file path (including `~` expansion)
|
||||
- `file://` URL
|
||||
- `http://` and `https://` URL
|
||||
|
||||
Reference notes:
|
||||
|
||||
- Other URI schemes (for example `ftp://`) are rejected with `unsupported_pdf_reference`.
|
||||
- In sandbox mode, remote `http(s)` URLs are rejected.
|
||||
- With workspace-only file policy enabled, local file paths outside allowed roots are rejected.
|
||||
|
||||
## Execution modes
|
||||
|
||||
### Native provider mode
|
||||
|
||||
Native mode is used for provider `anthropic` and `google`.
|
||||
The tool sends raw PDF bytes directly to provider APIs.
|
||||
|
||||
Native mode limits:
|
||||
|
||||
- `pages` is not supported. If set, the tool returns an error.
|
||||
|
||||
### Extraction fallback mode
|
||||
|
||||
Fallback mode is used for non-native providers.
|
||||
|
||||
Flow:
|
||||
|
||||
1. Extract text from selected pages (up to `agents.defaults.pdfMaxPages`, default `20`).
|
||||
2. If extracted text length is below `200` chars, render selected pages to PNG images and include them.
|
||||
3. Send extracted content plus prompt to the selected model.
|
||||
|
||||
Fallback details:
|
||||
|
||||
- Page image extraction uses a pixel budget of `4,000,000`.
|
||||
- If the target model does not support image input and there is no extractable text, the tool errors.
|
||||
- Extraction fallback requires `pdfjs-dist` (and `@napi-rs/canvas` for image rendering).
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
pdfModel: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["openai/gpt-5-mini"],
|
||||
},
|
||||
pdfMaxBytesMb: 10,
|
||||
pdfMaxPages: 20,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See [Configuration Reference](/gateway/configuration-reference) for full field details.
|
||||
|
||||
## Output details
|
||||
|
||||
The tool returns text in `content[0].text` and structured metadata in `details`.
|
||||
|
||||
Common `details` fields:
|
||||
|
||||
- `model`: resolved model ref (`provider/model`)
|
||||
- `native`: `true` for native provider mode, `false` for fallback
|
||||
- `attempts`: fallback attempts that failed before success
|
||||
|
||||
Path fields:
|
||||
|
||||
- single PDF input: `details.pdf`
|
||||
- multiple PDF inputs: `details.pdfs[]` with `pdf` entries
|
||||
- sandbox path rewrite metadata (when applicable): `rewrittenFrom`
|
||||
|
||||
## Error behavior
|
||||
|
||||
- Missing PDF input: throws `pdf required: provide a path or URL to a PDF document`
|
||||
- Too many PDFs: returns structured error in `details.error = "too_many_pdfs"`
|
||||
- Unsupported reference scheme: returns `details.error = "unsupported_pdf_reference"`
|
||||
- Native mode with `pages`: throws clear `pages is not supported with native PDF providers` error
|
||||
|
||||
## Examples
|
||||
|
||||
Single PDF:
|
||||
|
||||
```json
|
||||
{
|
||||
"pdf": "/tmp/report.pdf",
|
||||
"prompt": "Summarize this report in 5 bullets"
|
||||
}
|
||||
```
|
||||
|
||||
Multiple PDFs:
|
||||
|
||||
```json
|
||||
{
|
||||
"pdfs": ["/tmp/q1.pdf", "/tmp/q2.pdf"],
|
||||
"prompt": "Compare risks and timeline changes across both documents"
|
||||
}
|
||||
```
|
||||
|
||||
Page-filtered fallback model:
|
||||
|
||||
```json
|
||||
{
|
||||
"pdf": "https://example.com/report.pdf",
|
||||
"pages": "1-3,7",
|
||||
"model": "openai/gpt-5-mini",
|
||||
"prompt": "Extract only customer-impacting incidents"
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave Search API, Perplexity direct/OpenRouter, Gemini Google Search grounding)"
|
||||
summary: "Web search + fetch tools (Brave, Perplexity, Gemini, Grok, and Kimi providers)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need Brave Search API key setup
|
||||
@@ -12,7 +12,7 @@ title: "Web Tools"
|
||||
|
||||
OpenClaw ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, or Gemini with Google Search grounding.
|
||||
- `web_search` — Search the web via Brave Search API (default), Perplexity Sonar, Gemini with Google Search grounding, Grok, or Kimi.
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@@ -31,11 +31,13 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- |
|
||||
| **Brave** (default) | Fast, structured results, free tier | Traditional search results | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| Provider | Pros | Cons | API Key |
|
||||
| ------------------- | -------------------------------------------- | ---------------------------------------------- | -------------------------------------------- |
|
||||
| **Brave** (default) | Fast, structured results | Traditional search results; AI-use terms apply | `BRAVE_API_KEY` |
|
||||
| **Perplexity** | AI-synthesized answers, citations, real-time | Requires Perplexity or OpenRouter access | `OPENROUTER_API_KEY` or `PERPLEXITY_API_KEY` |
|
||||
| **Gemini** | Google Search grounding, AI-synthesized | Requires Gemini API key | `GEMINI_API_KEY` |
|
||||
| **Grok** | xAI web-grounded responses | Requires xAI API key | `XAI_API_KEY` |
|
||||
| **Kimi** | Moonshot web search capability | Requires Moonshot API key | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
|
||||
See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for provider-specific details.
|
||||
|
||||
@@ -43,10 +45,11 @@ See [Brave Search setup](/brave-search) and [Perplexity Sonar](/perplexity) for
|
||||
|
||||
If no `provider` is explicitly set, OpenClaw auto-detects which provider to use based on available API keys, checking in this order:
|
||||
|
||||
1. **Brave** — `BRAVE_API_KEY` env var or `search.apiKey` config
|
||||
2. **Gemini** — `GEMINI_API_KEY` env var or `search.gemini.apiKey` config
|
||||
3. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `search.perplexity.apiKey` config
|
||||
4. **Grok** — `XAI_API_KEY` env var or `search.grok.apiKey` config
|
||||
1. **Brave** — `BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
|
||||
2. **Gemini** — `GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
|
||||
3. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
|
||||
4. **Perplexity** — `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` env var or `tools.web.search.perplexity.apiKey` config
|
||||
5. **Grok** — `XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
|
||||
|
||||
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
|
||||
|
||||
@@ -59,7 +62,7 @@ Set the provider in config:
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave", // or "perplexity" or "gemini"
|
||||
provider: "brave", // or "perplexity" or "gemini" or "grok" or "kimi"
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -91,9 +94,13 @@ Example: switch to Perplexity Sonar (direct API):
|
||||
2. In the dashboard, choose the **Data for Search** plan (not “Data for AI”) and generate an API key.
|
||||
3. Run `openclaw configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
|
||||
|
||||
Brave provides a free tier plus paid plans; check the Brave API portal for the
|
||||
Brave provides paid plans; check the Brave API portal for the
|
||||
current limits and pricing.
|
||||
|
||||
Brave Terms include restrictions on some AI-related uses of Search Results.
|
||||
Review the Brave Terms of Service and confirm your intended use is compliant.
|
||||
For legal questions, consult your counsel.
|
||||
|
||||
### Where to set the key (recommended)
|
||||
|
||||
**Recommended:** run `openclaw configure --section web`. It stores the key in
|
||||
@@ -208,6 +215,9 @@ Search the web using your configured provider.
|
||||
- API key for your chosen provider:
|
||||
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity**: `OPENROUTER_API_KEY`, `PERPLEXITY_API_KEY`, or `tools.web.search.perplexity.apiKey`
|
||||
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
|
||||
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
|
||||
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
|
||||
|
||||
### Config
|
||||
|
||||
|
||||
@@ -201,6 +201,19 @@ openclaw channels add
|
||||
}
|
||||
```
|
||||
|
||||
若使用 `connectionMode: "webhook"`,需设置 `verificationToken`。飞书 Webhook 服务默认绑定 `127.0.0.1`;仅在需要不同监听地址时设置 `webhookHost`。
|
||||
|
||||
#### 获取 Verification Token(仅 Webhook 模式)
|
||||
|
||||
使用 Webhook 模式时,需在配置中设置 `channels.feishu.verificationToken`。获取方式:
|
||||
|
||||
1. 在飞书开放平台打开您的应用
|
||||
2. 进入 **开发配置** → **事件与回调**
|
||||
3. 打开 **加密策略** 选项卡
|
||||
4. 复制 **Verification Token**(校验令牌)
|
||||
|
||||

|
||||
|
||||
### 通过环境变量配置
|
||||
|
||||
```bash
|
||||
@@ -228,6 +241,34 @@ export FEISHU_APP_SECRET="xxx"
|
||||
}
|
||||
```
|
||||
|
||||
### 配额优化
|
||||
|
||||
可通过以下可选配置减少飞书 API 调用:
|
||||
|
||||
- `typingIndicator`(默认 `true`):设为 `false` 时不发送“正在输入”状态。
|
||||
- `resolveSenderNames`(默认 `true`):设为 `false` 时不拉取发送者资料。
|
||||
|
||||
可在渠道级或账号级配置:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
typingIndicator: false,
|
||||
resolveSenderNames: false,
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
typingIndicator: true,
|
||||
resolveSenderNames: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 第三步:启动并测试
|
||||
@@ -280,7 +321,7 @@ openclaw pairing approve feishu <配对码>
|
||||
**1. 群组策略**(`channels.feishu.groupPolicy`):
|
||||
|
||||
- `"open"` = 允许群组中所有人(默认)
|
||||
- `"allowlist"` = 仅允许 `groupAllowFrom` 中的用户
|
||||
- `"allowlist"` = 仅允许 `groupAllowFrom` 中的群组
|
||||
- `"disabled"` = 禁用群组消息
|
||||
|
||||
**2. @提及要求**(`channels.feishu.groups.<chat_id>.requireMention`):
|
||||
@@ -321,14 +362,36 @@ openclaw pairing approve feishu <配对码>
|
||||
}
|
||||
```
|
||||
|
||||
### 仅允许特定用户在群组中使用
|
||||
### 仅允许特定群组
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["ou_xxx", "ou_yyy"],
|
||||
// 群组 ID 格式为 oc_xxx
|
||||
groupAllowFrom: ["oc_xxx", "oc_yyy"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 仅允许特定成员在群组中发信(发送者白名单)
|
||||
|
||||
除群组白名单外,该群组内**所有消息**均按发送者 open_id 校验:仅 `groups.<chat_id>.allowFrom` 中列出的用户消息会被处理,其他成员的消息会被忽略(此为发送者级白名单,不仅针对 /reset、/new 等控制命令)。
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["oc_xxx"],
|
||||
groups: {
|
||||
oc_xxx: {
|
||||
// 用户 open_id 格式为 ou_xxx
|
||||
allowFrom: ["ou_user1", "ou_user2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -428,12 +491,13 @@ openclaw pairing list feishu
|
||||
|
||||
### 多账号配置
|
||||
|
||||
如果需要管理多个飞书机器人:
|
||||
如果需要管理多个飞书机器人,可配置 `defaultAccount` 指定出站未显式指定 `accountId` 时使用的账号:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "main",
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
@@ -578,23 +642,29 @@ openclaw pairing list feishu
|
||||
|
||||
主要选项:
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
| ------------------------------------------------- | ------------------------------ | --------- |
|
||||
| `channels.feishu.enabled` | 启用/禁用渠道 | `true` |
|
||||
| `channels.feishu.domain` | API 域名(`feishu` 或 `lark`) | `feishu` |
|
||||
| `channels.feishu.accounts.<id>.appId` | 应用 App ID | - |
|
||||
| `channels.feishu.accounts.<id>.appSecret` | 应用 App Secret | - |
|
||||
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
|
||||
| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - |
|
||||
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
|
||||
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
|
||||
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
|
||||
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
|
||||
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
| ------------------------------------------------- | --------------------------------- | ---------------- |
|
||||
| `channels.feishu.enabled` | 启用/禁用渠道 | `true` |
|
||||
| `channels.feishu.domain` | API 域名(`feishu` 或 `lark`) | `feishu` |
|
||||
| `channels.feishu.connectionMode` | 事件传输模式(websocket/webhook) | `websocket` |
|
||||
| `channels.feishu.defaultAccount` | 出站路由默认账号 ID | `default` |
|
||||
| `channels.feishu.verificationToken` | Webhook 模式必填 | - |
|
||||
| `channels.feishu.webhookPath` | Webhook 路由路径 | `/feishu/events` |
|
||||
| `channels.feishu.webhookHost` | Webhook 监听地址 | `127.0.0.1` |
|
||||
| `channels.feishu.webhookPort` | Webhook 监听端口 | `3000` |
|
||||
| `channels.feishu.accounts.<id>.appId` | 应用 App ID | - |
|
||||
| `channels.feishu.accounts.<id>.appSecret` | 应用 App Secret | - |
|
||||
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
|
||||
| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - |
|
||||
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
|
||||
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
|
||||
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
|
||||
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
|
||||
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
|
||||
|
||||
---
|
||||
|
||||
@@ -614,6 +684,7 @@ openclaw pairing list feishu
|
||||
### 接收
|
||||
|
||||
- ✅ 文本消息
|
||||
- ✅ 富文本(帖子)
|
||||
- ✅ 图片
|
||||
- ✅ 文件
|
||||
- ✅ 音频
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"version": "2026.3.2",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
|
||||
export type BlueBubblesAccountResolveOpts = {
|
||||
serverUrl?: string;
|
||||
@@ -18,8 +19,24 @@ export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolv
|
||||
cfg: params.cfg ?? {},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = params.password?.trim() || account.config.password?.trim();
|
||||
const baseUrl =
|
||||
normalizeResolvedSecretInputString({
|
||||
value: params.serverUrl,
|
||||
path: "channels.bluebubbles.serverUrl",
|
||||
}) ||
|
||||
normalizeResolvedSecretInputString({
|
||||
value: account.config.serverUrl,
|
||||
path: `channels.bluebubbles.accounts.${account.accountId}.serverUrl`,
|
||||
});
|
||||
const password =
|
||||
normalizeResolvedSecretInputString({
|
||||
value: params.password,
|
||||
path: "channels.bluebubbles.password",
|
||||
}) ||
|
||||
normalizeResolvedSecretInputString({
|
||||
value: account.config.password,
|
||||
path: `channels.bluebubbles.accounts.${account.accountId}.password`,
|
||||
});
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
|
||||
25
extensions/bluebubbles/src/accounts.test.ts
Normal file
25
extensions/bluebubbles/src/accounts.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
describe("resolveBlueBubblesAccount", () => {
|
||||
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
normalizeAccountId,
|
||||
normalizeOptionalAccountId,
|
||||
} from "openclaw/plugin-sdk/account-id";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
export type ResolvedBlueBubblesAccount = {
|
||||
@@ -79,9 +80,9 @@ export function resolveBlueBubblesAccount(params: {
|
||||
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
||||
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const serverUrl = merged.serverUrl?.trim();
|
||||
const password = merged.password?.trim();
|
||||
const configured = Boolean(serverUrl && password);
|
||||
const serverUrl = normalizeSecretInputString(merged.serverUrl);
|
||||
const password = normalizeSecretInputString(merged.password);
|
||||
const configured = Boolean(serverUrl && hasConfiguredSecretInput(merged.password));
|
||||
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
|
||||
return {
|
||||
accountId,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
@@ -102,8 +103,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
||||
const assertPrivateApiEnabled = () => {
|
||||
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
|
||||
|
||||
@@ -10,6 +10,18 @@ describe("BlueBubblesConfigSchema", () => {
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password when serverUrl is set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires password when top-level serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
@@ -30,7 +31,7 @@ const bluebubblesAccountSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
serverUrl: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
password: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
@@ -49,8 +50,8 @@ const bluebubblesAccountSchema = z
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const serverUrl = value.serverUrl?.trim() ?? "";
|
||||
const password = value.password?.trim() ?? "";
|
||||
if (serverUrl && !password) {
|
||||
const passwordConfigured = hasConfiguredSecretInput(value.password);
|
||||
if (serverUrl && !passwordConfigured) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["password"],
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
} from "./monitor-shared.js";
|
||||
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
|
||||
|
||||
@@ -731,8 +732,8 @@ export async function processMessage(
|
||||
// surfacing dropped content (allowlist/mention/command gating).
|
||||
cacheInboundMessage();
|
||||
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
const maxBytes =
|
||||
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import {
|
||||
@@ -50,8 +50,11 @@ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
||||
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
const mockResolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
||||
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
||||
@@ -66,131 +69,57 @@ const mockMatchesMentionWithExplicit = vi.fn(
|
||||
},
|
||||
);
|
||||
const mockResolveRequireMention = vi.fn(() => false);
|
||||
const mockResolveGroupPolicy = vi.fn(() => "open");
|
||||
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
|
||||
type DispatchReplyParams = Parameters<
|
||||
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
|
||||
>[0];
|
||||
const EMPTY_DISPATCH_RESULT = {
|
||||
queuedFinal: false,
|
||||
counts: { tool: 0, block: 0, final: 0 },
|
||||
} as const;
|
||||
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (_params: DispatchReplyParams): Promise<void> => undefined,
|
||||
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
|
||||
);
|
||||
const mockHasControlCommand = vi.fn(() => false);
|
||||
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
id: "test-media.jpg",
|
||||
path: "/tmp/test-media.jpg",
|
||||
size: Buffer.byteLength("test"),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
|
||||
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
|
||||
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
|
||||
template: "channel+name+time",
|
||||
}));
|
||||
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
|
||||
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
||||
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
||||
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
||||
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length");
|
||||
const mockResolveChunkMode = vi.fn(() => "length" as const);
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return {
|
||||
version: "1.0.0",
|
||||
config: {
|
||||
loadConfig: vi.fn(() => ({})) as unknown as PluginRuntime["config"]["loadConfig"],
|
||||
writeConfigFile: vi.fn() as unknown as PluginRuntime["config"]["writeConfigFile"],
|
||||
},
|
||||
return createPluginRuntimeMock({
|
||||
system: {
|
||||
enqueueSystemEvent:
|
||||
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
||||
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
|
||||
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
||||
formatNativeDependencyHint: vi.fn(
|
||||
() => "",
|
||||
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||
detectMime: vi.fn() as unknown as PluginRuntime["media"]["detectMime"],
|
||||
mediaKindFromMime: vi.fn() as unknown as PluginRuntime["media"]["mediaKindFromMime"],
|
||||
isVoiceCompatibleAudio:
|
||||
vi.fn() as unknown as PluginRuntime["media"]["isVoiceCompatibleAudio"],
|
||||
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
|
||||
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
||||
},
|
||||
tts: {
|
||||
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
||||
},
|
||||
stt: {
|
||||
transcribeAudioFile: vi.fn() as unknown as PluginRuntime["stt"]["transcribeAudioFile"],
|
||||
},
|
||||
tools: {
|
||||
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
|
||||
createMemorySearchTool:
|
||||
vi.fn() as unknown as PluginRuntime["tools"]["createMemorySearchTool"],
|
||||
registerMemoryCli: vi.fn() as unknown as PluginRuntime["tools"]["registerMemoryCli"],
|
||||
enqueueSystemEvent: mockEnqueueSystemEvent,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText:
|
||||
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
|
||||
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
||||
chunkByNewline:
|
||||
mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
|
||||
chunkMarkdownTextWithMode:
|
||||
mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
|
||||
chunkTextWithMode:
|
||||
mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
|
||||
chunkMarkdownText: mockChunkMarkdownText,
|
||||
chunkByNewline: mockChunkByNewline,
|
||||
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
||||
chunkTextWithMode: mockChunkTextWithMode,
|
||||
resolveChunkMode:
|
||||
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
resolveTextChunkLimit: vi.fn(
|
||||
() => 4000,
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
||||
hasControlCommand:
|
||||
mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
|
||||
resolveMarkdownTableMode: vi.fn(
|
||||
() => "code",
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||
convertMarkdownTables: vi.fn(
|
||||
(text: string) => text,
|
||||
) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
|
||||
hasControlCommand: mockHasControlCommand,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
createReplyDispatcherWithTyping:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["createReplyDispatcherWithTyping"],
|
||||
resolveEffectiveMessagesConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveEffectiveMessagesConfig"],
|
||||
resolveHumanDelayConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["resolveHumanDelayConfig"],
|
||||
dispatchReplyFromConfig:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"],
|
||||
withReplyDispatcher: vi.fn(
|
||||
async ({
|
||||
dispatcher,
|
||||
run,
|
||||
onSettled,
|
||||
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dispatcher.markComplete();
|
||||
try {
|
||||
await dispatcher.waitForIdle();
|
||||
} finally {
|
||||
await onSettled?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
||||
finalizeInboundContext: vi.fn(
|
||||
(ctx: Record<string, unknown>) => ctx,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
formatAgentEnvelope:
|
||||
mockFormatAgentEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
||||
formatInboundEnvelope:
|
||||
mockFormatInboundEnvelope as unknown as PluginRuntime["channel"]["reply"]["formatInboundEnvelope"],
|
||||
formatAgentEnvelope: mockFormatAgentEnvelope,
|
||||
formatInboundEnvelope: mockFormatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions:
|
||||
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
@@ -199,105 +128,33 @@ function createMockRuntime(): PluginRuntime {
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply:
|
||||
mockBuildPairingReply as unknown as PluginRuntime["channel"]["pairing"]["buildPairingReply"],
|
||||
readAllowFromStore:
|
||||
mockReadAllowFromStore as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
|
||||
upsertPairingRequest:
|
||||
mockUpsertPairingRequest as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
||||
buildPairingReply: mockBuildPairingReply,
|
||||
readAllowFromStore: mockReadAllowFromStore,
|
||||
upsertPairingRequest: mockUpsertPairingRequest,
|
||||
},
|
||||
media: {
|
||||
fetchRemoteMedia:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"],
|
||||
saveMediaBuffer:
|
||||
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath:
|
||||
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
readSessionUpdatedAt:
|
||||
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
recordInboundSession:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
||||
recordSessionMetaFromInbound:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["recordSessionMetaFromInbound"],
|
||||
updateLastRoute:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["session"]["updateLastRoute"],
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
readSessionUpdatedAt: mockReadSessionUpdatedAt,
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes:
|
||||
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
||||
matchesMentionPatterns:
|
||||
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
||||
matchesMentionWithExplicit:
|
||||
mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
|
||||
},
|
||||
reactions: {
|
||||
shouldAckReaction,
|
||||
removeAckReactionAfterReply,
|
||||
buildMentionRegexes: mockBuildMentionRegexes,
|
||||
matchesMentionPatterns: mockMatchesMentionPatterns,
|
||||
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy:
|
||||
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention:
|
||||
mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
|
||||
},
|
||||
debounce: {
|
||||
// Create a pass-through debouncer that immediately calls onFlush
|
||||
createInboundDebouncer: vi.fn(
|
||||
(params: { onFlush: (items: unknown[]) => Promise<void> }) => ({
|
||||
enqueue: async (item: unknown) => {
|
||||
await params.onFlush([item]);
|
||||
},
|
||||
flushKey: vi.fn(),
|
||||
}),
|
||||
) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
|
||||
resolveInboundDebounceMs: vi.fn(
|
||||
() => 0,
|
||||
) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
|
||||
resolveRequireMention: mockResolveRequireMention,
|
||||
},
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers:
|
||||
mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
|
||||
isControlCommandMessage:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
|
||||
shouldComputeCommandAuthorized:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
|
||||
shouldHandleTextCommands:
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
|
||||
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
||||
},
|
||||
discord: {} as PluginRuntime["channel"]["discord"],
|
||||
activity: {} as PluginRuntime["channel"]["activity"],
|
||||
line: {} as PluginRuntime["channel"]["line"],
|
||||
slack: {} as PluginRuntime["channel"]["slack"],
|
||||
telegram: {} as PluginRuntime["channel"]["telegram"],
|
||||
signal: {} as PluginRuntime["channel"]["signal"],
|
||||
imessage: {} as PluginRuntime["channel"]["imessage"],
|
||||
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
|
||||
},
|
||||
events: {
|
||||
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
|
||||
onSessionTranscriptUpdate: vi.fn(
|
||||
() => () => {},
|
||||
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
|
||||
getChildLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})) as unknown as PluginRuntime["logging"]["getChildLogger"],
|
||||
},
|
||||
state: {
|
||||
resolveStateDir: vi.fn(
|
||||
() => "/tmp/openclaw",
|
||||
) as unknown as PluginRuntime["state"]["resolveStateDir"],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createMockAccount(
|
||||
@@ -404,604 +261,6 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
unregister?.();
|
||||
});
|
||||
|
||||
describe("webhook parsing + auth handling", () => {
|
||||
it("rejects non-POST requests", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it("accepts POST requests with valid JSON payload", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects requests with invalid JSON", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts URL-encoded payload wrappers", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
const encodedBody = new URLSearchParams({
|
||||
payload: JSON.stringify(payload),
|
||||
}).toString();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=test-password";
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = vi.fn();
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
// Advance past the 30s timeout
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
const handled = await handledPromise;
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(408);
|
||||
expect(req.destroy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests before reading the body", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=wrong-token";
|
||||
req.headers = {};
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
// Mock non-localhost request
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("authenticates via x-password header", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook",
|
||||
{
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
},
|
||||
{ "x-password": "secret-token" },
|
||||
);
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests with wrong password", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
||||
const accountA = createMockAccount({ password: "secret-token" });
|
||||
const accountB = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
const unregisterA = registerBlueBubblesWebhookTarget({
|
||||
account: accountA,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkA,
|
||||
});
|
||||
const unregisterB = registerBlueBubblesWebhookTarget({
|
||||
account: accountB,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkB,
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
||||
const accountStrict = createMockAccount({ password: "secret-token" });
|
||||
const accountWithoutPassword = createMockAccount({ password: undefined });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const sinkStrict = vi.fn();
|
||||
const sinkWithoutPassword = vi.fn();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
const unregisterStrict = registerBlueBubblesWebhookTarget({
|
||||
account: accountStrict,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkStrict,
|
||||
});
|
||||
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
||||
account: accountWithoutPassword,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkWithoutPassword,
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterStrict();
|
||||
unregisterNoPassword();
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
||||
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires authentication for loopback requests when password is configured", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress,
|
||||
};
|
||||
|
||||
const loopbackUnregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
loopbackUnregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
||||
const account = createMockAccount({ password: undefined });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ host: "localhost" },
|
||||
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
|
||||
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
||||
];
|
||||
for (const headers of headerVariants) {
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook",
|
||||
{
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
},
|
||||
headers,
|
||||
);
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unregistered webhook paths", async () => {
|
||||
const req = createMockRequest("POST", "/unregistered-path", {});
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it("parses chatId when provided as a string (webhook variant)", async () => {
|
||||
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||
|
||||
const account = createMockAccount({ groupPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello from group",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chatId: "123",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: { kind: "chat_id", chatId: 123 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
|
||||
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockClear();
|
||||
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
});
|
||||
|
||||
const account = createMockAccount({ groupPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello from group",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chat: { chatGuid: "iMessage;+;chat123456" },
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||
"chat_guid:iMessage;+;chat123456",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DM pairing behavior vs allowFrom", () => {
|
||||
it("allows DM from sender in allowFrom list", async () => {
|
||||
const account = createMockAccount({
|
||||
@@ -2508,6 +1767,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.onReplyStart?.();
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
@@ -2558,6 +1818,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await params.dispatcherOptions.onReplyStart?.();
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
await params.dispatcherOptions.onIdle?.();
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
@@ -2603,7 +1864,9 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
},
|
||||
};
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async () => undefined);
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
async () => EMPTY_DISPATCH_RESULT,
|
||||
);
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
@@ -2625,6 +1888,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
const account = createMockAccount();
|
||||
@@ -2676,6 +1940,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
const account = createMockAccount();
|
||||
@@ -2748,6 +2013,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
const account = createMockAccount();
|
||||
|
||||
862
extensions/bluebubbles/src/monitor.webhook-auth.test.ts
Normal file
862
extensions/bluebubbles/src/monitor.webhook-auth.test.ts
Normal file
@@ -0,0 +1,862 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import {
|
||||
handleBlueBubblesWebhookRequest,
|
||||
registerBlueBubblesWebhookTarget,
|
||||
resolveBlueBubblesMessageId,
|
||||
_resetBlueBubblesShortIdState,
|
||||
} from "./monitor.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./send.js", () => ({
|
||||
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
|
||||
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
|
||||
}));
|
||||
|
||||
vi.mock("./chat.js", () => ({
|
||||
markBlueBubblesChatRead: vi.fn().mockResolvedValue(undefined),
|
||||
sendBlueBubblesTyping: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./attachments.js", () => ({
|
||||
downloadBlueBubblesAttachment: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("test"),
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./reactions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
|
||||
return {
|
||||
...actual,
|
||||
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./history.js", () => ({
|
||||
fetchBlueBubblesHistory: vi.fn().mockResolvedValue({ entries: [], resolved: true }),
|
||||
}));
|
||||
|
||||
// Mock runtime
|
||||
const mockEnqueueSystemEvent = vi.fn();
|
||||
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
|
||||
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
|
||||
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
const mockResolveAgentRoute = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "bluebubbles",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
||||
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
||||
regexes.some((r) => r.test(text)),
|
||||
);
|
||||
const mockMatchesMentionWithExplicit = vi.fn(
|
||||
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
|
||||
if (params.explicitWasMentioned) {
|
||||
return true;
|
||||
}
|
||||
return params.mentionRegexes.some((regex) => regex.test(params.text));
|
||||
},
|
||||
);
|
||||
const mockResolveRequireMention = vi.fn(() => false);
|
||||
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
|
||||
type DispatchReplyParams = Parameters<
|
||||
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
|
||||
>[0];
|
||||
const EMPTY_DISPATCH_RESULT = {
|
||||
queuedFinal: false,
|
||||
counts: { tool: 0, block: 0, final: 0 },
|
||||
} as const;
|
||||
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
|
||||
);
|
||||
const mockHasControlCommand = vi.fn(() => false);
|
||||
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
id: "test-media.jpg",
|
||||
path: "/tmp/test-media.jpg",
|
||||
size: Buffer.byteLength("test"),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
const mockResolveStorePath = vi.fn(() => "/tmp/sessions.json");
|
||||
const mockReadSessionUpdatedAt = vi.fn(() => undefined);
|
||||
const mockResolveEnvelopeFormatOptions = vi.fn(() => ({}));
|
||||
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
||||
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
||||
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
||||
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length" as const);
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return createPluginRuntimeMock({
|
||||
system: {
|
||||
enqueueSystemEvent: mockEnqueueSystemEvent,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: mockChunkMarkdownText,
|
||||
chunkByNewline: mockChunkByNewline,
|
||||
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
||||
chunkTextWithMode: mockChunkTextWithMode,
|
||||
resolveChunkMode:
|
||||
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
hasControlCommand: mockHasControlCommand,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
formatAgentEnvelope: mockFormatAgentEnvelope,
|
||||
formatInboundEnvelope: mockFormatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions:
|
||||
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: mockBuildPairingReply,
|
||||
readAllowFromStore: mockReadAllowFromStore,
|
||||
upsertPairingRequest: mockUpsertPairingRequest,
|
||||
},
|
||||
media: {
|
||||
saveMediaBuffer:
|
||||
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
readSessionUpdatedAt: mockReadSessionUpdatedAt,
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: mockBuildMentionRegexes,
|
||||
matchesMentionPatterns: mockMatchesMentionPatterns,
|
||||
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy:
|
||||
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention: mockResolveRequireMention,
|
||||
},
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createMockAccount(
|
||||
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
||||
): ResolvedBlueBubblesAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): IncomingMessage {
|
||||
if (headers.host === undefined) {
|
||||
headers.host = "localhost";
|
||||
}
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
||||
const hasAuthHeader =
|
||||
headers["x-guid"] !== undefined ||
|
||||
headers["x-password"] !== undefined ||
|
||||
headers["x-bluebubbles-guid"] !== undefined ||
|
||||
headers.authorization !== undefined;
|
||||
if (!hasAuthQuery && !hasAuthHeader) {
|
||||
parsedUrl.searchParams.set("password", "test-password");
|
||||
}
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = method;
|
||||
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
|
||||
req.headers = headers;
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
||||
|
||||
// Emit body data after a microtask
|
||||
// oxlint-disable-next-line no-floating-promises
|
||||
Promise.resolve().then(() => {
|
||||
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
||||
req.emit("data", Buffer.from(bodyStr));
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: "",
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((data?: string) => {
|
||||
res.body = data ?? "";
|
||||
}),
|
||||
} as unknown as ServerResponse & { body: string; statusCode: number };
|
||||
return res;
|
||||
}
|
||||
|
||||
const flushAsync = async () => {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
function getFirstDispatchCall(): DispatchReplyParams {
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
if (!callArgs) {
|
||||
throw new Error("expected dispatch call arguments");
|
||||
}
|
||||
return callArgs;
|
||||
}
|
||||
|
||||
describe("BlueBubbles webhook monitor", () => {
|
||||
let unregister: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset short ID state between tests for predictable behavior
|
||||
_resetBlueBubblesShortIdState();
|
||||
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
mockResolveRequireMention.mockReturnValue(false);
|
||||
mockHasControlCommand.mockReturnValue(false);
|
||||
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
||||
mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]);
|
||||
|
||||
setBlueBubblesRuntime(createMockRuntime());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregister?.();
|
||||
});
|
||||
|
||||
describe("webhook parsing + auth handling", () => {
|
||||
it("rejects non-POST requests", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = createMockRequest("GET", "/bluebubbles-webhook", {});
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it("accepts POST requests with valid JSON payload", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects requests with invalid JSON", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{");
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts URL-encoded payload wrappers", async () => {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
const encodedBody = new URLSearchParams({
|
||||
payload: JSON.stringify(payload),
|
||||
}).toString();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody);
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("ok");
|
||||
});
|
||||
|
||||
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=test-password";
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = vi.fn();
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
// Advance past the 30s timeout
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
const handled = await handledPromise;
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(408);
|
||||
expect(req.destroy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests before reading the body", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook?password=wrong-token";
|
||||
req.headers = {};
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
// Mock non-localhost request
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("authenticates via x-password header", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook",
|
||||
{
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
},
|
||||
{ "x-password": "secret-token" },
|
||||
);
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests with wrong password", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=wrong-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
||||
const accountA = createMockAccount({ password: "secret-token" });
|
||||
const accountB = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
const unregisterA = registerBlueBubblesWebhookTarget({
|
||||
account: accountA,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkA,
|
||||
});
|
||||
const unregisterB = registerBlueBubblesWebhookTarget({
|
||||
account: accountB,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkB,
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
||||
const accountStrict = createMockAccount({ password: "secret-token" });
|
||||
const accountWithoutPassword = createMockAccount({ password: undefined });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const sinkStrict = vi.fn();
|
||||
const sinkWithoutPassword = vi.fn();
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "192.168.1.100",
|
||||
};
|
||||
|
||||
const unregisterStrict = registerBlueBubblesWebhookTarget({
|
||||
account: accountStrict,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkStrict,
|
||||
});
|
||||
const unregisterNoPassword = registerBlueBubblesWebhookTarget({
|
||||
account: accountWithoutPassword,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: sinkWithoutPassword,
|
||||
});
|
||||
unregister = () => {
|
||||
unregisterStrict();
|
||||
unregisterNoPassword();
|
||||
};
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
||||
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires authentication for loopback requests when password is configured", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
});
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress,
|
||||
};
|
||||
|
||||
const loopbackUnregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
loopbackUnregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
||||
const account = createMockAccount({ password: undefined });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ host: "localhost" },
|
||||
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
|
||||
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
||||
];
|
||||
for (const headers of headerVariants) {
|
||||
const req = createMockRequest(
|
||||
"POST",
|
||||
"/bluebubbles-webhook",
|
||||
{
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
},
|
||||
},
|
||||
headers,
|
||||
);
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unregistered webhook paths", async () => {
|
||||
const req = createMockRequest("POST", "/unregistered-path", {});
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
it("parses chatId when provided as a string (webhook variant)", async () => {
|
||||
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||
|
||||
const account = createMockAccount({ groupPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello from group",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chatId: "123",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
target: { kind: "chat_id", chatId: 123 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
|
||||
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockClear();
|
||||
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
const account = createMockAccount({ groupPolicy: "open" });
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello from group",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chat: { chatGuid: "iMessage;+;chat123456" },
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||
"chat_guid:iMessage;+;chat123456",
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
81
extensions/bluebubbles/src/onboarding.secret-input.test.ts
Normal file
81
extensions/bluebubbles/src/onboarding.secret-input.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
addWildcardAllowFrom: vi.fn(),
|
||||
formatDocsLink: (_url: string, fallback: string) => fallback,
|
||||
hasConfiguredSecretInput: (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const ref = value as { source?: unknown; provider?: unknown; id?: unknown };
|
||||
const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec";
|
||||
return (
|
||||
validSource &&
|
||||
typeof ref.provider === "string" &&
|
||||
ref.provider.trim().length > 0 &&
|
||||
typeof ref.id === "string" &&
|
||||
ref.id.trim().length > 0
|
||||
);
|
||||
},
|
||||
mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries,
|
||||
normalizeSecretInputString: (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
},
|
||||
normalizeAccountId: (value?: string | null) =>
|
||||
value && value.trim().length > 0 ? value : "default",
|
||||
promptAccountId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("bluebubbles onboarding SecretInput", () => {
|
||||
it("preserves existing password SecretRef when user keeps current credential", async () => {
|
||||
const { blueBubblesOnboardingAdapter } = await import("./onboarding.js");
|
||||
type ConfigureContext = Parameters<
|
||||
NonNullable<typeof blueBubblesOnboardingAdapter.configure>
|
||||
>[0];
|
||||
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
|
||||
const confirm = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true) // keep server URL
|
||||
.mockResolvedValueOnce(true) // keep password SecretRef
|
||||
.mockResolvedValueOnce(false); // keep default webhook path
|
||||
const text = vi.fn();
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = {
|
||||
confirm,
|
||||
text,
|
||||
note,
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const context = {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: passwordRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await blueBubblesOnboardingAdapter.configure(context);
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
@@ -222,8 +223,11 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
}
|
||||
|
||||
// Prompt for password
|
||||
let password = resolvedAccount.config.password?.trim();
|
||||
if (!password) {
|
||||
const existingPassword = resolvedAccount.config.password;
|
||||
const existingPasswordText = normalizeSecretInputString(existingPassword);
|
||||
const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword);
|
||||
let password: unknown = existingPasswordText;
|
||||
if (!hasConfiguredPassword) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Enter the BlueBubbles server password.",
|
||||
@@ -247,6 +251,8 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
password = String(entered).trim();
|
||||
} else if (!existingPasswordText) {
|
||||
password = existingPassword;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
export type BlueBubblesProbe = BaseProbeResult & {
|
||||
@@ -35,8 +36,8 @@ export async function fetchBlueBubblesServerInfo(params: {
|
||||
accountId?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesServerInfo | null> {
|
||||
const baseUrl = params.baseUrl?.trim();
|
||||
const password = params.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(params.baseUrl);
|
||||
const password = normalizeSecretInputString(params.password);
|
||||
if (!baseUrl || !password) {
|
||||
return null;
|
||||
}
|
||||
@@ -138,8 +139,8 @@ export async function probeBlueBubbles(params: {
|
||||
password?: string | null;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesProbe> {
|
||||
const baseUrl = params.baseUrl?.trim();
|
||||
const password = params.password?.trim();
|
||||
const baseUrl = normalizeSecretInputString(params.baseUrl);
|
||||
const password = normalizeSecretInputString(params.password);
|
||||
if (!baseUrl) {
|
||||
return { ok: false, error: "serverUrl not configured" };
|
||||
}
|
||||
|
||||
19
extensions/bluebubbles/src/secret-input.ts
Normal file
19
extensions/bluebubbles/src/secret-input.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
|
||||
|
||||
export function buildSecretInputSchema() {
|
||||
return z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
source: z.enum(["env", "file", "exec"]),
|
||||
provider: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user