diff --git a/.dockerignore b/.dockerignore
index 73d00fff147b..3a8e436d515a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -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/**
diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
index f02fbddb3e85..be79a1ce5937 100644
--- a/.github/actionlint.yaml
+++ b/.github/actionlint.yaml
@@ -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
diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml
index 334cd3c24fb9..1b70385ca546 100644
--- a/.github/actions/setup-node-env/action.yml
+++ b/.github/actions/setup-node-env/action.yml
@@ -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"
diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml
index 8e25492ac922..e1e5a34abdaf 100644
--- a/.github/actions/setup-pnpm-store-cache/action.yml
+++ b/.github/actions/setup-pnpm-store-cache/action.yml
@@ -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 }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ed4063cc616b..a30087d6ec9f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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:
diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml
index 786ec5f66689..b278a2875476 100644
--- a/.github/workflows/docker-release.yml
+++ b/.github/workflows/docker-release.yml
@@ -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
diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml
index fd0ac45799da..1d36523d60af 100644
--- a/.github/workflows/install-smoke.yml
+++ b/.github/workflows/install-smoke.yml
@@ -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 .
diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml
index 26c0dcc106f8..13688bd0f257 100644
--- a/.github/workflows/sandbox-common-smoke.yml
+++ b/.github/workflows/sandbox-common-smoke.yml
@@ -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: |
diff --git a/.gitignore b/.gitignore
index d1bafb97c90e..29afb5e12612 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,7 @@ mise.toml
apps/android/.gradle/
apps/android/app/build/
apps/android/.cxx/
+apps/android/.kotlin/
# Bun build artifacts
*.bun-build
@@ -94,9 +95,9 @@ USER.md
!.agent/workflows/
/local/
package-lock.json
-.claude/settings.local.json
-.agents/*
-!.agents/maintainers.md
+.claude/
+.agents/
+.agents
.agent/
skills-lock.json
diff --git a/.pi/extensions/diff.ts b/.pi/extensions/diff.ts
index 037fa240afb1..9f8e718e892a 100644
--- a/.pi/extensions/diff.ts
+++ b/.pi/extensions/diff.ts
@@ -6,15 +6,7 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { DynamicBorder } from "@mariozechner/pi-coding-agent";
-import {
- Container,
- Key,
- matchesKey,
- type SelectItem,
- SelectList,
- Text,
-} from "@mariozechner/pi-tui";
+import { showPagedSelectList } from "./ui/paged-select";
interface FileInfo {
status: string;
@@ -108,87 +100,17 @@ export default function (pi: ExtensionAPI) {
}
};
- // Show file picker with SelectList
- await ctx.ui.custom((tui, theme, _kb, done) => {
- const container = new Container();
-
- // Top border
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
-
- // Title
- container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to diff")), 0, 0));
-
- // Build select items with colored status
- const items: SelectItem[] = files.map((f) => {
- let statusColor: string;
- switch (f.status) {
- case "M":
- statusColor = theme.fg("warning", f.status);
- break;
- case "A":
- statusColor = theme.fg("success", f.status);
- break;
- case "D":
- statusColor = theme.fg("error", f.status);
- break;
- case "?":
- statusColor = theme.fg("muted", f.status);
- break;
- default:
- statusColor = theme.fg("dim", f.status);
- }
- return {
- value: f,
- label: `${statusColor} ${f.file}`,
- };
- });
-
- const visibleRows = Math.min(files.length, 15);
- let currentIndex = 0;
-
- const selectList = new SelectList(items, visibleRows, {
- selectedPrefix: (t) => theme.fg("accent", t),
- selectedText: (t) => t, // Keep existing colors
- description: (t) => theme.fg("muted", t),
- scrollInfo: (t) => theme.fg("dim", t),
- noMatch: (t) => theme.fg("warning", t),
- });
- selectList.onSelect = (item) => {
+ const items = files.map((file) => ({
+ value: file,
+ label: `${file.status} ${file.file}`,
+ }));
+ await showPagedSelectList({
+ ctx,
+ title: " Select file to diff",
+ items,
+ onSelect: (item) => {
void openSelected(item.value as FileInfo);
- };
- selectList.onCancel = () => done();
- selectList.onSelectionChange = (item) => {
- currentIndex = items.indexOf(item);
- };
- container.addChild(selectList);
-
- // Help text
- container.addChild(
- new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
- );
-
- // Bottom border
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
-
- return {
- render: (w) => container.render(w),
- invalidate: () => container.invalidate(),
- handleInput: (data) => {
- // Add paging with left/right
- if (matchesKey(data, Key.left)) {
- // Page up - clamp to 0
- currentIndex = Math.max(0, currentIndex - visibleRows);
- selectList.setSelectedIndex(currentIndex);
- } else if (matchesKey(data, Key.right)) {
- // Page down - clamp to last
- currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
- selectList.setSelectedIndex(currentIndex);
- } else {
- selectList.handleInput(data);
- }
- tui.requestRender();
- },
- };
+ },
});
},
});
diff --git a/.pi/extensions/files.ts b/.pi/extensions/files.ts
index bba2760d0322..e1325303521f 100644
--- a/.pi/extensions/files.ts
+++ b/.pi/extensions/files.ts
@@ -6,15 +6,7 @@
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { DynamicBorder } from "@mariozechner/pi-coding-agent";
-import {
- Container,
- Key,
- matchesKey,
- type SelectItem,
- SelectList,
- Text,
-} from "@mariozechner/pi-tui";
+import { showPagedSelectList } from "./ui/paged-select";
interface FileEntry {
path: string;
@@ -113,82 +105,30 @@ export default function (pi: ExtensionAPI) {
}
};
- // Show file picker with SelectList
- await ctx.ui.custom((tui, theme, _kb, done) => {
- const container = new Container();
-
- // Top border
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
-
- // Title
- container.addChild(new Text(theme.fg("accent", theme.bold(" Select file to open")), 0, 0));
-
- // Build select items with colored operations
- const items: SelectItem[] = files.map((f) => {
- const ops: string[] = [];
- if (f.operations.has("read")) {
- ops.push(theme.fg("muted", "R"));
- }
- if (f.operations.has("write")) {
- ops.push(theme.fg("success", "W"));
- }
- if (f.operations.has("edit")) {
- ops.push(theme.fg("warning", "E"));
- }
- const opsLabel = ops.join("");
- return {
- value: f,
- label: `${opsLabel} ${f.path}`,
- };
- });
-
- const visibleRows = Math.min(files.length, 15);
- let currentIndex = 0;
-
- const selectList = new SelectList(items, visibleRows, {
- selectedPrefix: (t) => theme.fg("accent", t),
- selectedText: (t) => t, // Keep existing colors
- description: (t) => theme.fg("muted", t),
- scrollInfo: (t) => theme.fg("dim", t),
- noMatch: (t) => theme.fg("warning", t),
- });
- selectList.onSelect = (item) => {
- void openSelected(item.value as FileEntry);
- };
- selectList.onCancel = () => done();
- selectList.onSelectionChange = (item) => {
- currentIndex = items.indexOf(item);
- };
- container.addChild(selectList);
-
- // Help text
- container.addChild(
- new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
- );
-
- // Bottom border
- container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
-
+ const items = files.map((file) => {
+ const ops: string[] = [];
+ if (file.operations.has("read")) {
+ ops.push("R");
+ }
+ if (file.operations.has("write")) {
+ ops.push("W");
+ }
+ if (file.operations.has("edit")) {
+ ops.push("E");
+ }
return {
- render: (w) => container.render(w),
- invalidate: () => container.invalidate(),
- handleInput: (data) => {
- // Add paging with left/right
- if (matchesKey(data, Key.left)) {
- // Page up - clamp to 0
- currentIndex = Math.max(0, currentIndex - visibleRows);
- selectList.setSelectedIndex(currentIndex);
- } else if (matchesKey(data, Key.right)) {
- // Page down - clamp to last
- currentIndex = Math.min(items.length - 1, currentIndex + visibleRows);
- selectList.setSelectedIndex(currentIndex);
- } else {
- selectList.handleInput(data);
- }
- tui.requestRender();
- },
+ value: file,
+ label: `${ops.join("")} ${file.path}`,
};
});
+ await showPagedSelectList({
+ ctx,
+ title: " Select file to open",
+ items,
+ onSelect: (item) => {
+ void openSelected(item.value as FileEntry);
+ },
+ });
},
});
}
diff --git a/.pi/extensions/prompt-url-widget.ts b/.pi/extensions/prompt-url-widget.ts
index 2bb56b104ea3..e39c7fd949bb 100644
--- a/.pi/extensions/prompt-url-widget.ts
+++ b/.pi/extensions/prompt-url-widget.ts
@@ -114,6 +114,17 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
}
};
+ const renderPromptMatch = (ctx: ExtensionContext, match: PromptMatch) => {
+ setWidget(ctx, match);
+ applySessionName(ctx, match);
+ void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
+ const title = meta?.title?.trim();
+ const authorText = formatAuthor(meta?.author);
+ setWidget(ctx, match, title, authorText);
+ applySessionName(ctx, match, title);
+ });
+ };
+
pi.on("before_agent_start", async (event, ctx) => {
if (!ctx.hasUI) {
return;
@@ -123,14 +134,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
return;
}
- setWidget(ctx, match);
- applySessionName(ctx, match);
- void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
- const title = meta?.title?.trim();
- const authorText = formatAuthor(meta?.author);
- setWidget(ctx, match, title, authorText);
- applySessionName(ctx, match, title);
- });
+ renderPromptMatch(ctx, match);
});
pi.on("session_switch", async (_event, ctx) => {
@@ -177,14 +181,7 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) {
return;
}
- setWidget(ctx, match);
- applySessionName(ctx, match);
- void fetchGhMetadata(pi, match.kind, match.url).then((meta) => {
- const title = meta?.title?.trim();
- const authorText = formatAuthor(meta?.author);
- setWidget(ctx, match, title, authorText);
- applySessionName(ctx, match, title);
- });
+ renderPromptMatch(ctx, match);
};
pi.on("session_start", async (_event, ctx) => {
diff --git a/.pi/extensions/ui/paged-select.ts b/.pi/extensions/ui/paged-select.ts
new file mode 100644
index 000000000000..a92db66bc685
--- /dev/null
+++ b/.pi/extensions/ui/paged-select.ts
@@ -0,0 +1,82 @@
+import { DynamicBorder } from "@mariozechner/pi-coding-agent";
+import {
+ Container,
+ Key,
+ matchesKey,
+ type SelectItem,
+ SelectList,
+ Text,
+} from "@mariozechner/pi-tui";
+
+type CustomUiContext = {
+ ui: {
+ custom: (
+ render: (
+ tui: { requestRender: () => void },
+ theme: {
+ fg: (tone: string, text: string) => string;
+ bold: (text: string) => string;
+ },
+ kb: unknown,
+ done: () => void,
+ ) => {
+ render: (width: number) => string;
+ invalidate: () => void;
+ handleInput: (data: string) => void;
+ },
+ ) => Promise;
+ };
+};
+
+export async function showPagedSelectList(params: {
+ ctx: CustomUiContext;
+ title: string;
+ items: SelectItem[];
+ onSelect: (item: SelectItem) => void;
+}): Promise {
+ await params.ctx.ui.custom((tui, theme, _kb, done) => {
+ const container = new Container();
+
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+ container.addChild(new Text(theme.fg("accent", theme.bold(params.title)), 0, 0));
+
+ const visibleRows = Math.min(params.items.length, 15);
+ let currentIndex = 0;
+
+ const selectList = new SelectList(params.items, visibleRows, {
+ selectedPrefix: (text) => theme.fg("accent", text),
+ selectedText: (text) => text,
+ description: (text) => theme.fg("muted", text),
+ scrollInfo: (text) => theme.fg("dim", text),
+ noMatch: (text) => theme.fg("warning", text),
+ });
+ selectList.onSelect = (item) => params.onSelect(item);
+ selectList.onCancel = () => done();
+ selectList.onSelectionChange = (item) => {
+ currentIndex = params.items.indexOf(item);
+ };
+ container.addChild(selectList);
+
+ container.addChild(
+ new Text(theme.fg("dim", " ↑↓ navigate • ←→ page • enter open • esc close"), 0, 0),
+ );
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
+
+ return {
+ render: (width) => container.render(width),
+ invalidate: () => container.invalidate(),
+ handleInput: (data) => {
+ if (matchesKey(data, Key.left)) {
+ currentIndex = Math.max(0, currentIndex - visibleRows);
+ selectList.setSelectedIndex(currentIndex);
+ } else if (matchesKey(data, Key.right)) {
+ currentIndex = Math.min(params.items.length - 1, currentIndex + visibleRows);
+ selectList.setSelectedIndex(currentIndex);
+ } else {
+ selectList.handleInput(data);
+ }
+ tui.requestRender();
+ },
+ };
+ });
+}
diff --git a/.pi/prompts/landpr.md b/.pi/prompts/landpr.md
index 95e4692f3e55..2d0553a7336b 100644
--- a/.pi/prompts/landpr.md
+++ b/.pi/prompts/landpr.md
@@ -9,7 +9,7 @@ Input
- If ambiguous: ask.
Do (end-to-end)
-Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
+Goal: PR must end in GitHub state = MERGED (never CLOSED). Prefer `gh pr merge --squash`; use `--rebase` only when preserving commit history is required.
1. Assign PR to self:
- `gh pr edit --add-assignee @me`
@@ -37,8 +37,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
- Implement fixes + add/adjust tests
- Update `CHANGELOG.md` and mention `#` + `@$contrib`
9. Decide merge strategy:
- - Rebase if we want to preserve commit history
- - Squash if we want a single clean commit
+ - Squash (preferred): use when we want a single clean commit
+ - Rebase: use only when we explicitly want to preserve commit history
- If unclear, ask
10. Full gate (BEFORE commit):
- `pnpm lint && pnpm build && pnpm test`
@@ -54,8 +54,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
```
13. Merge PR (must show MERGED on GitHub):
- - Rebase: `gh pr merge --rebase`
- - Squash: `gh pr merge --squash`
+ - Squash (preferred): `gh pr merge --squash`
+ - Rebase (history-preserving fallback): `gh pr merge --rebase`
- Never `gh pr close` (closing is wrong)
14. Sync main:
- `git checkout main`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0025b1e7330..1a8558b29543 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,19 +2,230 @@
Docs: https://docs.openclaw.ai
+## 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
+
+- 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.
+- 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.
+- 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 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.
+- 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.
+- 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 @Glucksberg.
+- 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:` instead of `user:` 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.
+- 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.
+- 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.
+- 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.
+- 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.
+- 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.
+- 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)
+- 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/.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 @Glucksberg.
+- 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/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.
+- 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..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//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 `` 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
### Changes
-- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
+- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.
- Android/Nodes: add `camera.list`, `device.permissions`, `device.health`, and `notifications.actions` (`open`/`dismiss`/`reply`) on Android nodes, plus first-class node-tool actions for the new device/notification commands. (#28260) Thanks @obviyus.
- Discord/Thread bindings: replace fixed TTL lifecycle with inactivity (`idleHours`, default 24h) plus optional hard `maxAgeHours` lifecycle controls, and add `/session idle` + `/session max-age` commands for focused thread-bound sessions. (#27845) Thanks @osolmaz.
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
-- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
-- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
- Android/Gateway capability refresh: add live Android capability integration coverage and node canvas capability refresh wiring, plus runtime hardening for A2UI readiness retries, scoped canvas URL normalization, debug diagnostics JSON, and JavaScript MIME delivery. (#28388) Thanks @obviyus.
- Android/Nodes parity: add `system.notify`, `photos.latest`, `contacts.search`/`contacts.add`, `calendar.events`/`calendar.add`, and `motion.activity`/`motion.pedometer`, with motion sensor-aware command gating and improved activity sampling reliability. (#29398) Thanks @obviyus.
+- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
+- Web UI/Cron i18n: localize cron page labels, filters, form help text, and validation/error messaging in English and zh-CN. (#29315) Thanks @BUGKillerKing.
- CLI/Config: add `openclaw config file` to print the active config file path resolved from `OPENCLAW_CONFIG_PATH` or the default location. (#26256) thanks @cyb1278588254.
- Feishu/Docx tables + uploads: add `feishu_doc` actions for Docx table creation/cell writing (`create_table`, `write_table_cells`, `create_table_with_values`) and image/file uploads (`upload_image`, `upload_file`) with stricter create/upload error handling for missing `document_id` and placeholder cleanup failures. (#20304) Thanks @xuhao1.
- Feishu/Reactions: add inbound `im.message.reaction.created_v1` handling, route verified reactions through synthetic inbound turns, and harden verification with timeout + fail-closed filtering so non-bot or unverified reactions are dropped. (#16716) Thanks @schumilin.
@@ -36,15 +247,42 @@ Docs: https://docs.openclaw.ai
### Fixes
-- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
+- 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.
+- Feishu/Sessions announce group targets: normalize `group:` and `channel:` Feishu targets to `chat_id` routing so `sessions_send` announce delivery no longer sends group chat IDs via `user_id` API params. Fixes #31426.
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
+- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
+- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
+- 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.
+- 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.
+- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
+- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
+- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
+- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
+- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
+- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
+- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
+- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
+- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
+- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
+- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
+- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
+- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
+- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
+- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
+- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
+- Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus.
- LINE/Voice transcription: classify M4A voice media as `audio/mp4` (not `video/mp4`) by checking the MPEG-4 `ftyp` major brand (`M4A ` / `M4B `), restoring voice transcription for LINE voice messages. Landed from contributor PR #31151 by @scoootscooob. Thanks @scoootscooob.
- Slack/Announce target account routing: enable session-backed announce-target lookup for Slack so multi-account announces resolve the correct `accountId` instead of defaulting to bot-token context. Landed from contributor PR #31028 by @taw0002. Thanks @taw0002.
- 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.
-- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
-- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
-- 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.
- 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.
- 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.
@@ -64,23 +302,13 @@ Docs: https://docs.openclaw.ai
- Android/Nodes notification wake flow: enable Android `system.notify` default allowlist, emit `notifications.changed` events for posted/removed notifications (excluding OpenClaw app-owned notifications), canonicalize notification session keys before enqueue/wake routing, and skip heartbeat wakes when consecutive notification summaries dedupe. (#29440) Thanks @obviyus.
- Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
-- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
- Feishu/Inbound rich-text parsing: preserve `share_chat` payload summaries when available and add explicit parsing for rich-text `code`/`code_block`/`pre` tags so forwarded and code-heavy messages keep useful context in agent input. (#28591) Thanks @kevinWangSheng.
- Feishu/Post markdown parsing: parse rich-text `post` payloads through a shared markdown-aware parser with locale-wrapper support, preserved mention/image metadata extraction, and inline/fenced code fidelity for agent input rendering. (#12755) Thanks @WilsonLiu95.
- Telegram/Outbound chunking: route oversize splitting through the shared outbound pipeline (including subagents), retry Telegram sends when escaped HTML exceeds limits, and preserve boundary whitespace when retry re-splitting rendered chunks so plain-text/transcript fidelity is retained. (#29342, #27317; follow-up to #27461) Thanks @obviyus.
- Slack/Native commands: register Slack native status as `/agentstatus` (Slack-reserved `/status`) so manifest slash command registration stays valid while text `/status` still works. Landed from contributor PR #29032 by @maloqab. Thanks @maloqab.
- Android/Camera clip: remove `camera.clip` HTTP-upload fallback to base64 so clip transport is deterministic and fail-loud, and reject non-positive `maxWidth` values so invalid inputs fall back to the safe resize default. (#28229) Thanks @obviyus.
- Android/Gateway canvas capability refresh: send `node.canvas.capability.refresh` with object `params` (`{}`) from Android node runtime so gateway object-schema validation accepts refresh retries and A2UI host recovery works after scoped capability expiry. (#28413) Thanks @obviyus.
-- 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.
-- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
-- 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.
-- CLI/Install: add an npm-link fallback to fix CLI startup `Permission denied` failures (`exit 127`) on affected installs. (#17151) Thanks @sskyu and @vincentkoc.
- Onboarding/Custom providers: improve verification reliability for slower local endpoints (for example Ollama) during setup. (#27380) Thanks @Sid-Qin.
-- 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.
-- 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.
-- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.
@@ -92,59 +320,24 @@ Docs: https://docs.openclaw.ai
- Feishu/API quota controls: add `typingIndicator` and `resolveSenderNames` config flags (top-level and per-account) so operators can disable typing reactions and sender-name lookup requests while keeping default behavior unchanged. (#10513) Thanks @BigUncle.
- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
-- Sessions/Internal routing: preserve established external `lastTo`/`lastChannel` routes for internal/non-deliverable turns, with added coverage for no-fallback internal routing behavior. Landed from contributor PR #30941 by @graysurf. Thanks @graysurf.
- Control UI/Debug log layout: render Debug Event Log payloads at full width to prevent payload JSON from being squeezed into a narrow side column. Landed from contributor PR #30978 by @stozo04. Thanks @stozo04.
-- Auto-reply/NO_REPLY: strip `NO_REPLY` token from mixed-content messages instead of leaking raw control text to end users. Landed from contributor PR #31080 by @scoootscooob. Thanks @scoootscooob.
- Install/npm: fix npm global install deprecation warnings. (#28318) Thanks @vincentkoc.
- Update/Global npm: fallback to `--omit=optional` when global `npm update` fails so optional dependency install failures no longer abort update flows. (#24896) Thanks @xinhuagu and @vincentkoc.
-- Inbound metadata/Multi-account routing: include `account_id` in trusted inbound metadata so multi-account channel sessions can reliably disambiguate the receiving account in prompt context. Landed from contributor PR #30984 by @Stxle2. Thanks @Stxle2.
- Model directives/Auth profiles: split `/model` profile suffixes at the first `@` after the last slash so email-based auth profile IDs (for example OAuth profile IDs) resolve correctly. Landed from contributor PR #30932 by @haosenwang1018. Thanks @haosenwang1018.
-- Cron/Delivery mode none: send explicit `delivery: { mode: "none" }` from cron editor for both add and update flows so previous announce delivery is actually cleared. Landed from contributor PR #31145 by @byungsker. Thanks @byungsker.
-- Cron editor viewport: make the sticky cron edit form independently scrollable with viewport-bounded height so lower fields/actions are reachable on shorter screens. Landed from contributor PR #31133 by @Sid-Qin. Thanks @Sid-Qin.
-- Agents/Thinking fallback: when providers reject unsupported thinking levels without enumerating alternatives, retry with `think=off` to avoid hard failure during model/provider fallback chains. Landed from contributor PR #31002 by @yfge. Thanks @yfge.
- Ollama/Embedded runner base URL precedence: prioritize configured provider `baseUrl` over model defaults for embedded Ollama runs so Docker and remote-host setups avoid localhost fetch failures. (#30964) Thanks @stakeswky.
-- Agents/Failover reason classification: avoid false rate-limit classification from incidental `tpm` substrings by matching TPM as a standalone token/phrase and keeping auth-context errors on the auth path. Landed from contributor PR #31007 by @HOYALIM. Thanks @HOYALIM.
-- 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/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
-- Gateway/Auth: improve device-auth v2 migration diagnostics so operators get clearer guidance when legacy clients connect. (#28305) Thanks @vincentkoc.
-- CLI/Ollama config: allow `config set` for Ollama `apiKey` without predeclared provider config. (#29299) Thanks @vincentkoc.
- Ollama/Autodiscovery: harden autodiscovery and warning behavior. (#29201) Thanks @marcodelpin and @vincentkoc.
- Ollama/Context window: unify context window handling across discovery, merge, and OpenAI-compatible transport paths. (#29205) Thanks @Sid-Qin, @jimmielightner, and @vincentkoc.
-- Agents/Ollama: demote empty-discovery logging from `warn` to `debug` to reduce noisy warnings in normal edge-case discovery flows. (#26379) Thanks @byungsker.
- fix(model): preserve reasoning in provider fallback resolution. (#29285) Fixes #25636. Thanks @vincentkoc.
- Docker/Image permissions: normalize `/app/extensions`, `/app/.agent`, and `/app/.agents` to directory mode `755` and file mode `644` during image build so plugin discovery does not block inherited world-writable paths. (#30191) Fixes #30139. Thanks @edincampara.
- OpenAI Responses/Compaction: rewrite and unify the OpenAI Responses store patches to treat empty `baseUrl` as non-direct, honor `compat.supportsStore=false`, and auto-inject server-side compaction `context_management` for compatible direct OpenAI models (with per-model opt-out/threshold overrides). Landed from contributor PRs #16930 (@OiPunk), #22441 (@EdwardWu7), and #25088 (@MoerAI). Thanks @OiPunk, @EdwardWu7, and @MoerAI.
-- Sandbox/Browser Docker: pass `OPENCLAW_BROWSER_NO_SANDBOX=1` to sandbox browser containers and bump sandbox browser security hash epoch so existing containers are recreated and pick up the env on upgrade. (#29879) Thanks @Lukavyi.
- Usage normalization: clamp negative prompt/input token values to zero (including `prompt_tokens` alias inputs) so `/usage` and TUI usage displays cannot show nonsensical negative counts. Landed from contributor PR #31211 by @scoootscooob. Thanks @scoootscooob.
- Secrets/Auth profiles: normalize inline SecretRef `token`/`key` values to canonical `tokenRef`/`keyRef` before persistence, and keep explicit `keyRef` precedence when inline refs are also present. Landed from contributor PR #31047 by @minupla. Thanks @minupla.
-- Tools/Edit workspace boundary errors: preserve the real `Path escapes workspace root` failure path instead of surfacing a misleading access/file-not-found error when editing outside workspace roots. Landed from contributor PR #31015 by @haosenwang1018. Thanks @haosenwang1018.
-- Browser/Open & navigate: accept `url` as an alias parameter for `open` and `navigate`. (#29260) Thanks @vincentkoc.
- Codex/Usage window: label weekly usage window as `Week` instead of `Day`. (#26267) Thanks @Sid-Qin.
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
-- Sandbox/mkdirp boundary checks: allow directory-safe boundary validation for existing in-boundary subdirectories, preventing false `cannot create directories` failures in sandbox write mode. (#30610) Thanks @glitch418x.
-- 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.
-- Telegram/Voice fallback reply chunking: apply reply reference, quote text, and inline buttons only to the first fallback text chunk when voice delivery is blocked, preventing over-quoted multi-chunk replies. Landed from contributor PR #31067 by @xdanger. Thanks @xdanger.
-- Feishu/System preview prompt leakage: stop enqueuing inbound Feishu message previews as system events so user preview text is not injected into later turns as trusted `System:` context. Landed from contributor PR #31209 by @stakeswky. Thanks @stakeswky.
-- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #29610, #30432, #30331, and #29501. Thanks @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
-- Feishu/Typing replay suppression: skip typing indicators for stale replayed inbound messages after compaction using message-age checks with second/millisecond timestamp normalization, preventing old-message reaction floods while preserving typing for fresh messages. Landed from contributor PR #30709 by @arkyu2077. Thanks @arkyu2077.
## Unreleased
-### Changes
-
-- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
-- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
-- OpenAI/Streaming transport: make `openai` Responses WebSocket-first by default (`transport: "auto"` with SSE fallback), add shared OpenAI WS stream/connection runtime wiring with per-session cleanup, and preserve server-side compaction payload mutation (`store` + `context_management`) on the WS path.
-- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
-- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
-
-### Breaking
-
-- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
-- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
-
### Fixes
- Feishu/Multi-account + reply reliability: add `channels.feishu.defaultAccount` outbound routing support with schema validation, prevent inbound preview text from leaking into prompt system events, keep quoted-message extraction text-first (post/interactive/file placeholders instead of raw JSON), route Feishu video sends as `msg_type: "file"`, and avoid websocket event blocking by using non-blocking event handling in monitor dispatch. Landed from contributor PRs #31209, #29610, #30432, #30331, and #29501. Thanks @stakeswky, @hclsys, @bmendonca3, @patrick-yingxi-pan, and @zwffff.
@@ -171,6 +364,7 @@ Docs: https://docs.openclaw.ai
- 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..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.
- 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.
- Nodes/Screen recording guardrails: cap `nodes` tool `screen_record` `durationMs` to 5 minutes at both schema-validation and runtime invocation layers to prevent long-running blocking captures from unbounded durations. Landed from contributor PR #31106 by @BlueBirdBack. Thanks @BlueBirdBack.
- Telegram/Empty final replies: skip outbound send for null/undefined final text payloads without media so Telegram typing indicators do not linger on `text must be non-empty` errors, with added regression coverage for undefined final payload dispatch. Landed from contributor PRs #30969 by @haosenwang1018 and #30746 by @rylena. Thanks @haosenwang1018 and @rylena.
@@ -184,6 +378,7 @@ Docs: https://docs.openclaw.ai
- Feishu/Doc create permissions: remove caller-controlled owner fields from `feishu_doc` create and bind optional grant behavior to trusted Feishu requester context (`grant_to_requester`), preventing principal selection via tool arguments. (#31184) Thanks @Takhoffman.
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
- Cron/Store EBUSY fallback: retry `rename` on `EBUSY` and use `copyFile` fallback on Windows when replacing cron store files so busy-file contention no longer causes false write failures. (#16932) Thanks @sudhanva-chakra.
+- Cron/Isolated payload selection: ignore `isError` payloads when deriving summary/output/delivery payload fallbacks, while preserving error-only fallback behavior when no non-error payload exists. (#21454) Thanks @Diaspar4u.
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.
- Cron/Timer hot-loop guard: enforce a minimum timer re-arm delay when stale past-due jobs would otherwise trigger repeated `setTimeout(0)` loops, preventing event-loop saturation and log-flood behavior. (#29853) Thanks @FlamesCN.
- Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn.
@@ -196,9 +391,10 @@ Docs: https://docs.openclaw.ai
- Security/Audit: flag `gateway.controlUi.allowedOrigins=["*"]` as a high-risk configuration (severity based on bind exposure), and add a Feishu doc-tool warning that `owner_open_id` on `feishu_doc` create can grant document permissions.
- Slack/download-file scoping: thread/channel-aware `download-file` actions now propagate optional scope context and reject downloads when Slack metadata definitively shows the file is outside the requested channel/thread, while preserving legacy behavior when share metadata is unavailable.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.
+- Security/Sandbox media staging: block destination symlink escapes in `stageSandboxMedia` by replacing direct destination copies with root-scoped safe writes for both local and SCP-staged attachments, preventing out-of-workspace file overwrite through `media/inbound` alias traversal. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
- Node host/service auth env: include `OPENCLAW_GATEWAY_TOKEN` in `openclaw node install` service environments (with `CLAWDBOT_GATEWAY_TOKEN` compatibility fallback) so installed node services keep remote gateway token auth across restart/reboot. Fixes #31041. Thanks @OneStepAt4time for reporting, @byungsker, @liuxiaopai-ai, and @vincentkoc.
- Security/Subagents sandbox inheritance: block sandboxed sessions from spawning cross-agent subagents that would run unsandboxed, preventing runtime sandbox downgrade via `sessions_spawn agentId`. Thanks @tdjackey for reporting.
-- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.1`). Thanks @tdjackey for reporting.
+- Security/Workspace safe writes: harden `writeFileWithinRoot` against symlink-retarget TOCTOU races by opening existing files without truncation, creating missing files with exclusive create, deferring truncation until post-open identity+boundary validation, and removing out-of-root create artifacts on blocked races; added regression tests for truncate/create race paths. This ships in the next npm release (`2026.3.2`). Thanks @tdjackey for reporting.
- Control UI/Cron editor: include `{ mode: "none" }` in `cron.update` patches when editing an existing job and selecting “Result delivery = None (internal)”, so saved jobs no longer keep stale announce delivery mode. Fixes #31075.
- Telegram/Restart polling teardown: stop the Telegram bot instance when a polling cycle exits so in-process SIGUSR1 restarts fully tear down old long-poll loops before restart, reducing post-restart `getUpdates` 409 conflict storms. Fixes #31107. Landed from contributor PR #31141 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
- Security/Node metadata policy: harden node platform classification against Unicode confusables and switch unknown platform defaults to a conservative allowlist that excludes `system.run`/`system.which` unless explicitly allowlisted, preventing metadata canonicalization drift from broadening node command permissions. Thanks @tdjackey for reporting.
@@ -240,6 +436,7 @@ Docs: https://docs.openclaw.ai
- Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6.
- Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf.
- Cron tool/update flat params: recover top-level update patch fields when models omit the `patch` wrapper, and allow flattened update keys through tool input schema validation so `cron.update` no longer fails with `patch required` for valid flat payloads. (#23221)
+- Cron/Announce delivery status: keep isolated cron runs in `ok` state when execution succeeds but announce delivery fails (for example transient `pairing required`), while preserving `delivered=false` and delivery error context for visibility. (#31082) Thanks @YuzuruS.
- Agents/Message tool scoping: include other configured channels in scoped `message` tool action enum + description so isolated/cron runs can discover and invoke cross-channel actions without schema validation failures. Landed from contributor PR #20840 by @altaywtf. Thanks @altaywtf.
- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington.
- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11.
@@ -558,28 +755,8 @@ Docs: https://docs.openclaw.ai
- Security/Exec companion host: forward canonical `system.run` display text (not payload-only shell snippets) to the macOS exec host, and enforce rawCommand/argv consistency there for shell-wrapper positional-argv carriers and env-modifier preludes, preventing companion-side approval/display drift. Thanks @tdjackey for reporting.
- Security/Exec approvals: fail closed when transparent dispatch-wrapper unwrapping exceeds the depth cap, so nested `/usr/bin/env` chains cannot bypass shell-wrapper approval gating in `allowlist` + `ask=on-miss` mode. Thanks @tdjackey for reporting.
- Security/Exec: limit default safe-bin trusted directories to immutable system paths (`/bin`, `/usr/bin`) and require explicit opt-in (`tools.exec.safeBinTrustedDirs`) for package-manager/user bin paths (for example Homebrew), add security-audit findings for risky trusted-dir choices, warn at runtime when explicitly trusted dirs are group/world writable, and add doctor hints when configured `safeBins` resolve outside trusted dirs. Thanks @tdjackey for reporting.
-- 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.
-- Agents/Billing classification: prevent long assistant/user-facing text from being rewritten as billing failures while preserving explicit `status/code/http 402` detection for oversized structured error payloads. (#25680, #25661) Thanks @lairtonlelis.
-- 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.
-- Sessions/Tool-result guard: avoid generating synthetic `toolResult` entries for assistant turns that ended with `stopReason: "aborted"` or `"error"`, preventing orphaned tool-use IDs from triggering downstream API validation errors. (#25429) Thanks @mikaeldiakhate-cell.
- Gateway/Sessions: preserve `modelProvider` on `sessions.reset` and avoid incorrect provider prefixes for legacy session models. (#25874) Thanks @lbo728.
-- Usage accounting: parse Moonshot/Kimi `cached_tokens` fields (including `prompt_tokens_details.cached_tokens`) into normalized cache-read usage metrics. (#25436) Thanks @Elarwei001.
-- Doctor/Sandbox: when sandbox mode is enabled but Docker is unavailable, surface a clear actionable warning (including failure impact and remediation) instead of a mild “skip checks” note. (#25438) Thanks @mcaxtr.
-- Config/Meta: accept numeric `meta.lastTouchedAt` timestamps and coerce them to ISO strings, preserving compatibility with agent edits that write `Date.now()` values. (#25491) Thanks @mcaxtr.
-- Auto-reply/Reset hooks: guarantee native `/new` and `/reset` flows emit command/reset hooks even on early-return command paths, with dedupe protection to avoid double hook emission. (#25459) Thanks @chilu18.
-- Hooks/Slug generator: resolve session slug model from the agent’s effective model (including defaults/fallback resolution) instead of raw agent-primary config only. (#25485) Thanks @SudeepMalipeddi.
-- Slack/DM routing: treat `D*` channel IDs as direct messages even when Slack sends an incorrect `channel_type`, preventing DM traffic from being misclassified as channel/group chats. (#25479) Thanks @mcaxtr.
-- Models/Providers: preserve explicit user `reasoning` overrides when merging provider model config with built-in catalog metadata, so `reasoning: false` is no longer overwritten by catalog defaults. (#25314) Thanks @lbo728.
-- Exec approvals: treat bare allowlist `*` as a true wildcard for parsed executables, including unresolved PATH lookups, so global opt-in allowlists work as configured. (#25250) Thanks @widingmarcus-cyber.
-- Gateway/Auth: allow trusted-proxy authenticated Control UI websocket sessions to skip device pairing when device identity is absent, preventing false `pairing required` failures behind trusted reverse proxies. (#25428) Thanks @SidQin-cyber.
-- Agents/Tool dispatch: await block-reply flush before tool execution starts so buffered block replies preserve message ordering around tool calls. (#25427) Thanks @SidQin-cyber.
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
-- iOS/Signing: improve `scripts/ios-team-id.sh` for Xcode 16+ by falling back to Xcode-managed provisioning profiles, add actionable guidance when an Apple account exists but no Team ID can be resolved, and ignore Xcode `xcodebuild` output directories (`apps/ios/build`, `apps/shared/OpenClawKit/build`, `Swabble/build`). (#22773) Thanks @brianleach.
-- macOS/Menu bar: stop reusing the injector delegate for the "Usage cost (30 days)" submenu to prevent recursive submenu injection loops when opening cost history. (#25341) Thanks @yingchunbai.
-- Control UI/Chat images: route image-click opens through a shared safe-open helper (allowing only safe URL schemes) and open new tabs with opener isolation to block tabnabbing. (#18685, #25444, #25847) Thanks @Mariana-Codebase and @shakkernerd.
-- CLI/Doctor: correct stale recovery hints to use valid commands (`openclaw gateway status --deep` and `openclaw configure --section model`). (#24485) Thanks @chilu18.
-- CLI/Memory search: accept `--query ` for `openclaw memory search` (while keeping positional query support), and emit a clear error when neither form is provided. (#25904, #25857) Thanks @niceysam and @stakeswky.
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
## 2026.2.23
@@ -633,7 +810,6 @@ Docs: https://docs.openclaw.ai
- Plugins/Install: when npm install returns 404 for bundled channel npm specs, fallback to bundled channel sources and complete install/enable persistence instead of failing plugin install. (#12849) Thanks @vincentkoc.
- Gemini OAuth/Auth: resolve npm global shim install layouts while discovering Gemini CLI credentials, preventing false "Gemini CLI not found" onboarding/auth failures when shim paths are on `PATH`. (#27585) Thanks @ehgamemo and @vincentkoc.
- Providers/Groq: avoid classifying Groq TPM limit errors as context overflow so throttling paths no longer trigger overflow recovery logic. (#16176) Thanks @dddabtc.
-- Gateway/WS: close repeated post-handshake `unauthorized role:*` request floods per connection and sample duplicate rejection logs, preventing a single misbehaving client from degrading gateway responsiveness. (#20168) Thanks @acy103, @vibecodooor, and @vincentkoc.
- Gateway/Restart: treat child listener PIDs as owned by the service runtime PID during restart health checks to avoid false stale-process kills and restart timeouts on launchd/systemd. (#24696) Thanks @gumadeiras.
- Config/Write: apply `unsetPaths` with immutable path-copy updates so config writes never mutate caller-provided objects, and harden `openclaw config get/set/unset` path traversal by rejecting prototype-key segments and inherited-property traversal. (#24134) thanks @frankekn.
- Channels/WhatsApp: accept `channels.whatsapp.enabled` in config validation to match built-in channel auto-enable behavior, preventing `Unrecognized key: "enabled"` failures during channel setup. (#24263) Thanks @steipete.
@@ -890,6 +1066,8 @@ Docs: https://docs.openclaw.ai
- Security/Control UI avatars: harden `/avatar/:agentId` local avatar serving by rejecting symlink paths and requiring fd-level file identity + size checks before reads. Thanks @tdjackey for reporting.
- Security/MSTeams media: enforce allowlist checks for SharePoint reference attachment URLs and redirect targets during Graph-backed media fetches so redirect chains cannot escape configured media host boundaries. Thanks @tdjackey for reporting.
- Security/MSTeams media: route attachment auth-retry and Graph SharePoint download redirects through shared `safeFetch` so each hop is validated with allowlist + DNS/IP checks across the full redirect chain. (#23598) Thanks @Asm3r96 and @lewiswigmore.
+- Security/MSTeams auth redirect scoping: strip bearer auth on redirect hops outside `authAllowHosts` and gate SharePoint Graph auth-header injection by auth allowlist to prevent token bleed across redirect targets. (#25045) Thanks @bmendonca3.
+- MSTeams/reply reliability: when Bot Framework revokes thread turn-context proxies (for example debounced flush paths), fall back to proactive messaging/typing and continue pending sends without duplicating already delivered messages. (#27224) Thanks @openperf.
- Security/macOS discovery: fail closed for unresolved discovery endpoints by clearing stale remote selection values, use resolved service host only for SSH target derivation, and keep remote URL config aligned with resolved endpoint availability. (#21618) Thanks @bmendonca3.
- Chat/Usage/TUI: strip synthetic inbound metadata blocks (including `Conversation info` and trailing `Untrusted context` channel metadata wrappers) from displayed conversation history so internal prompt context no longer leaks into user-visible logs.
- CI/Tests: fix TypeScript case-table typing and lint assertion regressions so `pnpm check` passes again after Synology Chat landing. (#23012) Thanks @druide67.
@@ -995,8 +1173,6 @@ Docs: https://docs.openclaw.ai
- Gateway/Config: allow `gateway.customBindHost` in strict config validation when `gateway.bind="custom"` so valid custom bind-host configurations no longer fail startup. (#20318, fixes #20289) Thanks @MisterGuy420.
- Gateway/Pairing: tolerate legacy paired devices missing `roles`/`scopes` metadata in websocket upgrade checks and backfill metadata on reconnect. (#21447, fixes #21236) Thanks @joshavant.
- Gateway/Pairing/CLI: align read-scope compatibility in pairing/device-token checks and add local `openclaw devices` fallback recovery for loopback `pairing required` deadlocks, with explicit fallback notice to unblock approval bootstrap flows. (#21616) Thanks @shakkernerd.
-- Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman.
-- 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/Subagents: restore announce-chain delivery to agent injection, defer nested announce output until descendant follow-up content is ready, and prevent descendant deferrals from consuming announce retry budget so deep chains do not drop final completions. (#22223) Thanks @tyler6204.
- Agents/System Prompt: label allowlisted senders as authorized senders to avoid implying ownership. Thanks @thewilloftheshadow.
- Agents/Tool display: fix exec cwd suffix inference so `pushd ... && popd ... && ` does not keep stale `(in )` context in summaries. (#21925) Thanks @Lukavyi.
@@ -1369,7 +1545,6 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
-- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
- Telegram: replace inbound `` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
- Telegram: retry inbound media `getFile` 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.
@@ -1379,7 +1554,6 @@ Docs: https://docs.openclaw.ai
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
-- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
@@ -1870,9 +2044,6 @@ Docs: https://docs.openclaw.ai
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
-- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
-- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
-- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.
- Security: require auth for Gateway canvas host and A2UI assets. (#9518) Thanks @coygeek.
- Cron: fix scheduling and reminder delivery regressions; harden next-run recompute + timer re-arming + legacy schedule fields. (#9733, #9823, #9948, #9932) Thanks @tyler6204, @pycckuu, @j2h4u, @fujiwara-tofu-shop.
@@ -1990,7 +2161,6 @@ Docs: https://docs.openclaw.ai
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
-- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.
@@ -2060,62 +2230,10 @@ Docs: https://docs.openclaw.ai
## 2026.1.31
-### Changes
-
-- Docs: onboarding/install/i18n/exec-approvals/Control UI/exe.dev/cacheRetention updates + misc nav/typos. (#3050, #3461, #4064, #4675, #4729, #4763, #5003, #5402, #5446, #5474, #5663, #5689, #5694, #5967, #6270, #6300, #6311, #6416, #6487, #6550, #6789)
-- Telegram: use shared pairing store. (#6127) Thanks @obviyus.
-- Agents: add OpenRouter app attribution headers. Thanks @alexanderatallah.
-- Agents: add system prompt safety guardrails. (#5445) Thanks @joshp123.
-- Agents: update pi-ai to 0.50.9 and rename cacheControlTtl -> cacheRetention (with back-compat mapping).
-- Agents: extend CreateAgentSessionOptions with systemPrompt/skills/contextFiles.
-- Agents: add tool policy conformance snapshot (no runtime behavior change). (#6011)
-- Auth: update MiniMax OAuth hint + portal auth note copy.
-- Discord: inherit thread parent bindings for routing. (#3892) Thanks @aerolalit.
-- Gateway: inject timestamps into agent and chat.send messages. (#3705) Thanks @conroywhitney, @CashWilliams.
-- Gateway: require TLS 1.3 minimum for TLS listeners. (#5970) Thanks @loganaden.
-- Web UI: refine chat layout + extend session active duration.
-- CI: add formal conformance + alias consistency checks. (#5723, #5807)
-
### Fixes
-- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
-- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
- Plugins: validate plugin/hook install paths and reject traversal-like names.
-- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
-- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
-- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
-- Streaming: stabilize partial streaming filters.
-- Auto-reply: avoid referencing workspace files in /new greeting prompt. (#5706) Thanks @bravostation.
-- Tools: align tool execute adapters/signatures (legacy + parameter order + arg normalization).
- Tools: treat `"*"` tool allowlist entries as valid to avoid spurious unknown-entry warnings.
-- Skills: update session-logs paths from .clawdbot to .openclaw. (#4502)
-- Slack: harden media fetch limits and Slack file URL validation. (#6639) Thanks @davidiach.
-- Lint: satisfy curly rule after import sorting. (#6310)
-- Process: resolve Windows `spawn()` failures for npm-family CLIs by appending `.cmd` when needed. (#5815) Thanks @thejhinvirtuoso.
-- Discord: resolve PluralKit proxied senders for allowlists and labels. (#5838) Thanks @thewilloftheshadow.
-- Tlon: add timeout to SSE client fetch calls (CWE-400). (#5926)
-- Memory search: L2-normalize local embedding vectors to fix semantic search. (#5332)
-- Agents: align embedded runner + typings with pi-coding-agent API updates (pi 0.51.0).
-- Agents: ensure OpenRouter attribution headers apply in the embedded runner.
-- Agents: cap context window resolution for compaction safeguard. (#6187) Thanks @iamEvanYT.
-- System prompt: resolve overrides and hint using session_status for current date/time. (#1897, #1928, #2108, #3677)
-- Agents: fix Pi prompt template argument syntax. (#6543)
-- Subagents: fix announce failover race (always emit lifecycle end; timeout=0 means no-timeout). (#6621)
-- Teams: gate media auth retries.
-- Telegram: restore draft streaming partials. (#5543) Thanks @obviyus.
-- Onboarding: friendlier Windows onboarding message. (#6242) Thanks @shanselman.
-- TUI: prevent crash when searching with digits in the model selector.
-- Agents: wire before_tool_call plugin hook into tool execution. (#6570, #6660) Thanks @ryancnelson.
-- Browser: secure Chrome extension relay CDP sessions.
-- Docker: use container port for gateway command instead of host port. (#5110) Thanks @mise42.
-- Docker: start gateway CMD by default for container deployments. (#6635) Thanks @kaizen403.
-- fix(lobster): block arbitrary exec via lobsterPath/cwd injection (GHSA-4mhr-g7xj-cg8j). (#5335) Thanks @vignesh07.
-- Security: sanitize WhatsApp accountId to prevent path traversal. (#4610)
-- Security: restrict MEDIA path extraction to prevent LFI. (#4930)
-- Security: validate message-tool filePath/path against sandbox root. (#6398)
-- Security: block LD*/DYLD* env overrides for host exec. (#4896) Thanks @HassanFleyah.
-- Security: harden web tool content wrapping + file parsing safeguards. (#4058) Thanks @VACInc.
-- Security: enforce Twitch `allowFrom` allowlist gating (deny non-allowlisted senders). Thanks @MegaManSec.
## 2026.1.30
@@ -2862,7 +2980,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
-- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
### Changes
@@ -2871,7 +2988,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
- Telegram: scope inline buttons with allowlist default + callback gating in DMs/groups.
- Telegram: default reaction notifications to own.
-- Tools: improve `web_fetch` extraction using Readability (with fallback).
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
@@ -2911,7 +3027,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Sessions: keep per-session overrides when `/new` resets compaction counters. (#1050) — thanks @YuriNachos.
- Skills: allow OpenAI image-gen helper to handle URL or base64 responses. (#1050) — thanks @YuriNachos.
- WhatsApp: default response prefix only for self-chat, using identity name when set.
-- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: treat missing `imsg rpc` support as fatal to avoid restart loops.
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
@@ -3008,13 +3123,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.
- Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
-- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
-- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
-- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
-- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
-- Logging: tolerate `EIO` from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.
- Sandbox: restore `docker.binds` config validation and preserve configured PATH for `docker exec`. (#873) — thanks @akonyer.
-- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
#### macOS / Apps
diff --git a/Dockerfile b/Dockerfile
index 7e2baae51abc..b314ca3283d4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -57,6 +57,38 @@ RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
fi
+# Optionally install Docker CLI for sandbox container management.
+# Build with: docker build --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1 ...
+# Adds ~50MB. Only the CLI is installed — no Docker daemon.
+# Required for agents.defaults.sandbox to function in Docker deployments.
+ARG OPENCLAW_INSTALL_DOCKER_CLI=""
+ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
+RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
+ apt-get update && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ ca-certificates curl gnupg && \
+ install -m 0755 -d /etc/apt/keyrings && \
+ # Verify Docker apt signing key fingerprint before trusting it as a root key.
+ # 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 }')" && \
+ if [ -z "$actual_fingerprint" ] || [ "$actual_fingerprint" != "$expected_fingerprint" ]; then \
+ echo "ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-})" >&2; \
+ exit 1; \
+ fi && \
+ gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg.asc && \
+ rm -f /tmp/docker.gpg.asc && \
+ chmod a+r /etc/apt/keyrings/docker.gpg && \
+ printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\n' \
+ "$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
+ apt-get update && \
+ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
+ docker-ce-cli docker-compose-plugin && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
+ fi
+
USER node
COPY --chown=node:node . .
# Normalize copied plugin/agent paths so plugin safety checks do not reject
@@ -96,4 +128,6 @@ USER node
# - GET /healthz (liveness) and GET /readyz (readiness)
# - aliases: /health and /ready
# For external access from host/ingress, override bind to "lan" and set auth.
+HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
+ CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
diff --git a/README.md b/README.md
index b15cabfbbe9f..e4fba56d5ceb 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,7 @@
**OpenClaw** is a _personal AI assistant_ you run on your own devices.
-It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
+It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat). It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -32,15 +32,15 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
## Sponsors
-| OpenAI | Blacksmith | Convex |
-| ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
-| [](https://openai.com/) | [](https://blacksmith.sh/) | [](https://www.convex.dev/) |
+| OpenAI | Vercel | Blacksmith | Convex |
+| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------- |
+| [](https://openai.com/) | [](https://vercel.com/) | [](https://blacksmith.sh/) | [](https://www.convex.dev/) |
**Subscriptions (OAuth):**
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
-Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
+Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
## Models (selection + auth)
@@ -74,7 +74,7 @@ openclaw gateway --port 18789 --verbose
# Send a message
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
-# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
+# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WebChat)
openclaw agent --message "Ship checklist" --thinking high
```
@@ -126,9 +126,9 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
-- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
+- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), IRC, Microsoft Teams, Matrix, Feishu, LINE, Mattermost, Nextcloud Talk, Nostr, Synology Chat, Tlon, Twitch, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
-- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
+- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS and continuous voice on Android (ElevenLabs + system TTS fallback).
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.openclaw.ai/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.openclaw.ai/platforms/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.openclaw.ai/nodes).
@@ -150,14 +150,14 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
### Channels
-- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
+- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [IRC](https://docs.openclaw.ai/channels/irc), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams), [Matrix](https://docs.openclaw.ai/channels/matrix), [Feishu](https://docs.openclaw.ai/channels/feishu), [LINE](https://docs.openclaw.ai/channels/line), [Mattermost](https://docs.openclaw.ai/channels/mattermost), [Nextcloud Talk](https://docs.openclaw.ai/channels/nextcloud-talk), [Nostr](https://docs.openclaw.ai/channels/nostr), [Synology Chat](https://docs.openclaw.ai/channels/synology-chat), [Tlon](https://docs.openclaw.ai/channels/tlon), [Twitch](https://docs.openclaw.ai/channels/twitch), [Zalo](https://docs.openclaw.ai/channels/zalo), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Group routing](https://docs.openclaw.ai/channels/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
### Apps + nodes
- [macOS app](https://docs.openclaw.ai/platforms/macos): menu bar control plane, [Voice Wake](https://docs.openclaw.ai/nodes/voicewake)/PTT, [Talk Mode](https://docs.openclaw.ai/nodes/talk) overlay, [WebChat](https://docs.openclaw.ai/web/webchat), debug tools, [remote gateway](https://docs.openclaw.ai/gateway/remote) control.
-- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour pairing.
-- [Android node](https://docs.openclaw.ai/platforms/android): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, optional SMS.
+- [iOS node](https://docs.openclaw.ai/platforms/ios): [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), [Voice Wake](https://docs.openclaw.ai/nodes/voicewake), [Talk Mode](https://docs.openclaw.ai/nodes/talk), camera, screen recording, Bonjour + device pairing.
+- [Android node](https://docs.openclaw.ai/platforms/android): Connect tab (setup code/manual), chat sessions, voice tab, [Canvas](https://docs.openclaw.ai/platforms/mac/canvas), camera/screen recording, and Android device commands (notifications/location/SMS/photos/contacts/calendar/motion/app update).
- [macOS node mode](https://docs.openclaw.ai/nodes): system.run/notify + canvas/camera exposure.
### Tools + automation
@@ -185,7 +185,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
-WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
+WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / IRC / Microsoft Teams / Matrix / Feishu / LINE / Mattermost / Nextcloud Talk / Nostr / Synology Chat / Tlon / Twitch / Zalo / Zalo Personal / WebChat
│
▼
┌───────────────────────────────┐
@@ -207,7 +207,7 @@ WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBu
- **[Tailscale exposure](https://docs.openclaw.ai/gateway/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.openclaw.ai/gateway/remote)).
- **[Browser control](https://docs.openclaw.ai/tools/browser)** — openclaw‑managed Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.openclaw.ai/platforms/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui)).
-- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always‑on speech and continuous conversation.
+- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — wake words on macOS/iOS plus continuous voice on Android.
- **[Nodes](https://docs.openclaw.ai/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.
## Tailscale access (Gateway dashboard)
@@ -297,7 +297,7 @@ Note: signed builds required for macOS permissions to stick across rebuilds (see
### iOS node (optional)
-- Pairs as a node via the Bridge.
+- Pairs as a node over the Gateway WebSocket (device pairing).
- Voice trigger forwarding + Canvas surface.
- Controlled via `openclaw nodes …`.
@@ -305,8 +305,8 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
### Android node (optional)
-- Pairs via the same Bridge + pairing flow as iOS.
-- Exposes Canvas, Camera, and Screen capture commands.
+- Pairs as a WS node via device pairing (`openclaw devices ...`).
+- Exposes Connect/Chat/Voice tabs plus Canvas, Camera, Screen capture, and Android device command families.
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
## Agent workspace + skills
@@ -502,54 +502,58 @@ Special thanks to Adam Doppelt for lobster.bot.
Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SECURITY.md b/SECURITY.md
index 1dc51369f9a8..78a18b606db6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -57,6 +57,8 @@ These are frequently reported but are typically closed with no code change:
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
+- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
+- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
- Missing HSTS findings on default local/loopback deployments.
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
@@ -114,6 +116,8 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
+- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
+- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
@@ -149,6 +153,8 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
+- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
+- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
## Gateway and Node trust concept
diff --git a/appcast.xml b/appcast.xml
index ad76b36140d8..22e4df0b6980 100644
--- a/appcast.xml
+++ b/appcast.xml
@@ -2,6 +2,225 @@
OpenClaw
+ -
+
2026.3.2
+ Tue, 03 Mar 2026 04:30:29 +0000
+ https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
+ 2026030290
+ 2026.3.2
+ 15.0
+ OpenClaw 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
+
+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.
+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.
+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 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.
+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.
+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 @Glucksberg.
+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: instead of user: 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.
+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.
+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.
+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.
+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.
+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.
+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)
+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/.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 @Glucksberg.
+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/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.
+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..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//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 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.
+
+View full changelog
+]]>
+
+
-
2026.3.1
Mon, 02 Mar 2026 04:40:59 +0000
@@ -140,175 +359,5 @@
]]>
- -
-
2026.2.15
- Mon, 16 Feb 2026 05:04:34 +0100
- https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 202602150
- 2026.2.15
- 15.0
- OpenClaw 2026.2.15
-Changes
-
-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.
-Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
-Plugins: expose llm_input and llm_output hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
-Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set agents.defaults.subagents.maxSpawnDepth: 2 to allow sub-agents to spawn their own children. Includes maxChildrenPerAgent limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
-Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
-Cron/Gateway: add finished-run webhook delivery toggle (notify) and dedicated webhook auth token support (cron.webhookToken) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
-Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
-
-Fixes
-
-Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
-Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
-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.
-Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
-Gateway/Security: redact sensitive session/path details from status responses for non-admin clients; full details remain available to operator.admin. (#8590) Thanks @fr33d3m0n.
-Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (allowInsecureAuth / dangerouslyDisableDeviceAuth) when device identity is unavailable, preventing false missing scope failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
-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.
-Skills/Security: restrict download installer targetDir to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
-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.
-Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
-Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving passwordFile path exemptions, preventing accidental redaction of non-secret config values like maxTokens and IRC password-file paths. (#16042) Thanks @akramcodez.
-Dev tooling: harden git pre-commit hook against option injection from malicious filenames (for example --force), preventing accidental staging of ignored files. Thanks @mrthankyou.
-Gateway/Agent: reject malformed agent:-prefixed session keys (for example, agent:main) in agent and agent.identity.get instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
-Gateway/Chat: harden chat.send inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
-Gateway/Send: return an actionable error when send targets internal-only webchat, guiding callers to use chat.send or a deliverable channel. (#15703) Thanks @rodrigouroz.
-Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing script-src 'self'. Thanks @Adam55A-code.
-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.
-Agents/Sandbox: clarify system prompt path guidance so sandbox bash/exec uses container paths (for example /workspace) 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.
-Agents/Context: apply configured model contextWindow overrides after provider discovery so lookupContextTokens() honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
-Agents/Context: derive lookupContextTokens() 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.
-Agents/OpenAI: force store=true 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.
-Memory/FTS: make buildFtsQuery Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
-Auto-reply/Compaction: resolve memory/YYYY-MM-DD.md placeholders with timezone-aware runtime dates and append a Current time: line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
-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.
-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.
-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.
-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.
-Subagents/Models: preserve agents.defaults.model.fallbacks when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
-Telegram: omit message_thread_id for DM sends/draft previews and keep forum-topic handling (id=1 general omitted, non-general kept), preventing DM failures with 400 Bad Request: message thread not found. (#10942) Thanks @garnetlyx.
-Telegram: replace inbound placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
-Telegram: retry inbound media getFile 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.
-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.
-Discord: preserve channel session continuity when runtime payloads omit message.channelId by falling back to event/raw channel_id 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 sessionKey=unknown. (#17622) Thanks @shakkernerd.
-Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with _2 suffixes. (#17365) Thanks @seewhyme.
-Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
-Web UI/Agents: hide BOOTSTRAP.md in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
-Auto-reply/WhatsApp/TUI/Web: when a final assistant message is NO_REPLY and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show NO_REPLY placeholders. (#7010) Thanks @Morrowind-Xie.
-Cron: infer payload.kind="agentTurn" for model-only cron.update payload patches, so partial agent-turn updates do not fail validation when kind is omitted. (#15664) Thanks @rodrigouroz.
-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.
-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.
-TUI: suppress false (no output) 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.
-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.
-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.
-
-View full changelog
-]]>
-
-
- -
-
2026.2.26
- Thu, 26 Feb 2026 23:37:15 +0100
- https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
- 202602260
- 2026.2.26
- 15.0
- OpenClaw 2026.2.26
-Changes
-
-Highlight: External Secrets Management introduces a full openclaw secrets workflow (audit, configure, apply, reload) with runtime snapshot activation, strict secrets apply target-path validation, safer migration scrubbing, ref-only auth-profile support, and dedicated docs. (#26155) Thanks @joshavant.
-ACP/Thread-bound agents: make ACP agents first-class runtimes for thread sessions with acp spawn/send dispatch integration, acpx backend bridging, lifecycle controls, startup reconciliation, runtime cleanup, and coalesced thread replies. (#23580) thanks @osolmaz.
-Agents/Routing CLI: add openclaw agents bindings, openclaw agents bind, and openclaw agents unbind 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 openclaw channels add. (#27195) thanks @gumadeiras.
-Codex/WebSocket transport: make openai-codex WebSocket-first by default (transport: "auto" with SSE fallback), keep explicit per-model/runtime transport overrides, and add regression coverage + docs for transport selection.
-Onboarding/Plugins: let channel plugins own interactive onboarding flows with optional configureInteractive and configureWhenConfigured hooks while preserving the generic fallback path. (#27191) thanks @gumadeiras.
-Android/Nodes: add Android device capability plus device.status and device.info node commands, including runtime handler wiring and protocol/registry coverage for device status/info payloads. (#27664) Thanks @obviyus.
-Android/Nodes: add notifications.list support on Android nodes and expose nodes notifications_list in agent tooling for listing active device notifications. (#27344) thanks @obviyus.
-Docs/Contributing: add Nimrod Gutman to the maintainer roster in CONTRIBUTING.md. (#27840) Thanks @ngutman.
-
-Fixes
-
-Telegram/DM allowlist runtime inheritance: enforce dmPolicy: "allowlist" allowFrom requirements using effective account-plus-parent config across account-capable channels (Telegram, Discord, Slack, Signal, iMessage, IRC, BlueBubbles, WhatsApp), and align openclaw doctor checks to the same inheritance logic so DM traffic is not silently dropped after upgrades. (#27936) Thanks @widingmarcus-cyber.
-Delivery queue/recovery backoff: prevent retry starvation by persisting lastAttemptAt on failed sends and deferring recovery retries until each entry's lastAttemptAt + backoff window is eligible, while continuing to recover ready entries behind deferred ones. Landed from contributor PR #27710 by @Jimmy-xuzimo. Thanks @Jimmy-xuzimo.
-Google Chat/Lifecycle: keep Google Chat startAccount 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.
-Temp dirs/Linux umask: force 0700 permissions after temp-dir creation and self-heal existing writable temp dirs before trust checks so umask 0002 installs no longer crash-loop on startup. Landed from contributor PR #27860 by @stakeswky. (#27853) Thanks @stakeswky.
-Nextcloud Talk/Lifecycle: keep startAccount pending until abort and stop the webhook monitor on shutdown, preventing EADDRINUSE restart loops when the gateway manages account lifecycle. (#27897)
-Microsoft Teams/File uploads: acknowledge fileConsent/invoke immediately (invokeResponse 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.
-Queue/Drain/Cron reliability: harden lane draining with guaranteed draining flag reset on synchronous pump failures, reject new queue enqueues during gateway restart drain windows (instead of silently killing accepted tasks), add /stop queued-backlog cutoff metadata with stale-message skipping (while avoiding cross-session native-stop cutoff bleed), and raise isolated cron agentTurn outer safety timeout to avoid false 10-minute timeout races against longer agent session timeouts. (#27407, #27332, #27427)
-Typing/Main reply pipeline: always mark dispatch idle in agent-runner finalization so typing cleanup runs even when dispatcher onIdle does not fire, preventing stuck typing indicators after run completion. (#27250) Thanks @Sid-Qin.
-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.
-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.
-Telegram/sendChatAction 401 handling: add bounded exponential backoff + temporary local typing suppression after repeated unauthorized failures to stop unbounded sendChatAction retry loops that can trigger Telegram abuse enforcement and bot deletion. (#27415) Thanks @widingmarcus-cyber.
-Telegram/Webhook startup: clarify webhook config guidance, allow channels.telegram.webhookPort: 0 for ephemeral listener binding, and log both the local listener URL and Telegram-advertised webhook URL with the bound port. (#25732) thanks @huntharo.
-Browser/Chrome extension handshake: bind relay WS message handling before onopen and add non-blocking connect.challenge response handling for gateway-style handshake frames, avoiding stuck … badge states when challenge frames arrive immediately on connect. Landed from contributor PR #22571 by @pandego. (#22553)
-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)
-Browser/Fill relay + CLI parity: accept act.fill fields without explicit type by defaulting missing/empty type to text in both browser relay route parsing and openclaw browser fill 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.
-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.
-Agents/Canvas default node resolution: when multiple connected canvas-capable nodes exist and no single mac-* candidate is selected, default to the first connected candidate instead of failing with node required for implicit-node canvas tool calls. Landed from contributor PR #27444 by @carbaj03. Thanks @carbaj03.
-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)
-Hooks/Internal message:sent: forward sessionKey on outbound sends from agent delivery, cron isolated delivery, gateway receipt acks, heartbeat sends, session-maintenance warnings, and restart-sentinel recovery so internal message:sent hooks consistently dispatch with session context, including openclaw agent --deliver runs resumed via --session-id (without explicit --session-key). Landed from contributor PR #27584 by @qualiobra. Thanks @qualiobra.
-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)
-BlueBubbles/SSRF: auto-allowlist the configured serverUrl 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.
-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 openclaw onboard --reset default scope to config+creds+sessions (workspace deletion now requires --reset-scope full). (#26458, #27314) Thanks @jaden-clovervnd, @Sid-Qin, and @widingmarcus-cyber for fix direction in #26502, #26529, and #27492.
-NO_REPLY suppression: suppress NO_REPLY 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)
-Matrix/Group sender identity: preserve sender labels in Matrix group inbound prompt text (BodyForAgent) 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.
-Auto-reply/Streaming: suppress only exact NO_REPLY final replies while still filtering streaming partial sentinel fragments (NO_, NO_RE, HEARTBEAT_...) so substantive replies ending with NO_REPLY are delivered and partial silent tokens do not leak during streaming. (#19576) Thanks @aldoeliacim.
-Auto-reply/Inbound metadata: add a readable timestamp field to conversation info and ignore invalid/out-of-range timestamp values so prompt assembly never crashes on malformed timestamp inputs. (#17017) thanks @liuy.
-Typing/Run completion race: prevent post-run keepalive ticks from re-triggering typing callbacks by guarding triggerTyping() with runComplete, with regression coverage for no-restart behavior during run-complete/dispatch-idle boundaries. (#27413) Thanks @widingmarcus-cyber.
-Typing/Dispatch idle: force typing cleanup when markDispatchIdle never arrives after run completion, avoiding leaked typing keepalive loops in cron/announce edges. Landed from contributor PR #27541 by @Sid-Qin. (#27493)
-Telegram/Inline buttons: allow callback-query button handling in groups (including /models follow-up buttons) when group policy authorizes the sender, by removing the redundant callback allowlist gate that blocked open-policy groups. (#27343) Thanks @GodsBoy.
-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 no before no problem). (#27449) Thanks @emanuelst for the original fix direction in #19673.
-Browser/Extension relay CORS: handle /json* OPTIONS 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)
-Browser/Extension relay auth: allow ?token= query-param auth on relay /json* endpoints (consistent with relay WebSocket auth) so curl/devtools-style /json/version and /json/list probes work without requiring custom headers. Landed from contributor PR #26015 by @Sid-Qin. (#25928)
-Browser/Extension relay shutdown: flush pending extension-request timers/rejections during relay stop() before socket/server teardown so in-flight extension waits do not survive shutdown windows. Landed from contributor PR #24142 by @kevinWangSheng.
-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.
-Browser/Route decode hardening: guard malformed percent-encoding in relay target action routes and browser route-param decoding so crafted % paths return 400 instead of crashing/unhandled URI decode failures. Landed from contributor PR #11880 by @Yida-Dev.
-Feishu/Inbound message metadata: include inbound message_id in BodyForAgent 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.
-Feishu/Doc tools: route feishu_doc and feishu_app_scopes through the active agent account context (with explicit accountId 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.
-LINE/Inline directives auth: gate directive parsing (/model, /think, /verbose, /reasoning, /queue) on resolved authorization (command.isAuthorizedSender) so commands.allowFrom-authorized LINE senders are not silently stripped when raw CommandAuthorized is unset. Landed from contributor PR #27248 by @kevinWangSheng. (#27240)
-Onboarding/Gateway: seed default Control UI allowedOrigins for non-loopback binds during onboarding (localhost/127.0.0.1 plus custom bind host) so fresh non-loopback setups do not fail startup due to missing origin policy. (#26157) thanks @stakeswky.
-Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during pnpm install, reuse existing gateway token during docker-setup.sh reruns so .env 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.
-CLI/Gateway --force in non-root Docker: recover from lsof permission failures (EACCES/EPERM) by falling back to fuser kill + probe-based port checks, so openclaw gateway --force works for default container node user flows. (#27941)
-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.
-Sessions cleanup/Doctor: add openclaw sessions cleanup --fix-missing to prune store entries whose transcript files are missing, including doctor guidance and CLI coverage. Landed from contributor PR #27508 by @Sid-Qin. (#27422)
-Doctor/State integrity: ignore metadata-only slash routing sessions when checking recent missing transcripts so openclaw doctor no longer reports false-positive transcript-missing warnings for *:slash:* keys. (#27375) thanks @gumadeiras.
-CLI/Gateway status: force local gateway status probe host to 127.0.0.1 for bind=lan so co-located probes do not trip non-loopback plaintext WebSocket checks. (#26997) thanks @chikko80.
-CLI/Gateway auth: align gateway run --auth parsing/help text with supported gateway auth modes by accepting none and trusted-proxy (in addition to token/password) for CLI overrides. (#27469) thanks @s1korrrr.
-CLI/Daemon status TLS probe: use wss:// and forward local TLS certificate fingerprint for TLS-enabled gateway daemon probes so openclaw daemon status works with gateway.bind=lan + gateway.tls.enabled=true. (#24234) thanks @liuy.
-Podman/Default bind: change run-openclaw-podman.sh default gateway bind from lan to loopback and document explicit LAN opt-in with Control UI origin configuration. (#27491) thanks @robbyczgw-cla.
-Daemon/macOS launchd: forward proxy env vars into supervised service environments, keep LaunchAgent KeepAlive=true semantics, and harden restart sequencing to print -> bootout -> wait old pid exit -> bootstrap -> kickstart. (#27276) thanks @frankekn.
-Gateway/macOS restart-loop hardening: detect OpenClaw-managed supervisor markers during SIGUSR1 restart handoff, clean stale gateway PIDs before /restart launchctl/systemctl triggers, and set LaunchAgent ThrottleInterval=60 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)
-Models/MiniMax auth header defaults: set authHeader: true for both onboarding-generated MiniMax API providers and implicit built-in MiniMax (minimax, minimax-portal) provider templates so first requests no longer fail with MiniMax 401 authentication_error due to missing Authorization header. Landed from contributor PRs #27622 by @riccoyuanft and #27631 by @kevinWangSheng. (#27600, #15303)
-Auth/Auth profiles: normalize auth-profiles.json alias fields (mode -> type, apiKey -> key) before credential validation so entries copied from openclaw.json auth examples are no longer silently dropped. (#26950) thanks @byungsker.
-Models/Profile suffix parsing: centralize trailing @profile parsing and only treat @ as a profile separator when it appears after the final /, preserving model IDs like openai/@cf/... and openrouter/@preset/... across /model directive parsing and allowlist model resolution, with regression coverage.
-Models/OpenAI Codex config schema parity: accept openai-codex-responses in the config model API schema and TypeScript ModelApi union, with regression coverage for config validation. Landed from contributor PR #27501 by @AytuncYildizli. Thanks @AytuncYildizli.
-Agents/Models config: preserve agent-level provider apiKey and baseUrl during merge-mode models.json updates when agent values are present. (#27293) thanks @Sid-Qin.
-Azure OpenAI Responses: force store=true for azure-openai-responses direct responses API calls to avoid multi-turn 400 failures. Landed from contributor PR #27499 by @polarbear-Yang. (#27497)
-Security/Node exec approvals: require structured commandArgv approvals for host=node, enforce versioned systemRunBindingV1 matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add GIT_EXTERNAL_DIFF to blocked host env keys. This ships in the next npm release (2026.2.26). Thanks @tdjackey for reporting.
-Security/Plugin channel HTTP auth: normalize protected /api/channels path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed %-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (2026.2.26). Thanks @zpbrent for reporting.
-Security/Gateway node pairing: pin paired-device platform/deviceFamily 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 (2026.2.26). Thanks @76embiid21 for reporting.
-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 apply_patch writes from escaping sandbox/workspace boundaries via dangling symlinks. This ships in the next npm release (2026.2.26). Thanks @tdjackey for reporting.
-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 (2026.2.26). Thanks @tdjackey for reporting.
-Security/Config includes: harden $include 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 (2026.2.26). Thanks @zpbrent for reporting.
-Security/Node exec approvals hardening: freeze immutable approval-time execution plans (argv/cwd/agentId/sessionKey) via system.run.prepare, 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 (2026.2.26). Thanks @tdjackey for reporting.
-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 (2026.2.26). Thanks @tdjackey for reporting.
-Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned i-twilio-idempotency-token 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 (2026.2.26). Thanks @tdjackey for reporting.
-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.
-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 (2026.2.26). Thanks @tdjackey for reporting and @gumadeiras for implementation.
-Config/Plugins entries: treat unknown plugins.entries.* 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)
-Telegram native commands: degrade command registration on BOT_COMMANDS_TOO_MUCH by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)
-Web tools/Proxy: route web_search provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and web_fetch through a shared proxy-aware SSRF guard path so gateway installs behind HTTP_PROXY/HTTPS_PROXY/ALL_PROXY no longer fail with transport fetch failed errors. (#27430) thanks @kevinWangSheng.
-Android/Node invoke: remove native gateway WebSocket Origin 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.
-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)
-Cron/Hooks isolated routing: preserve canonical agent:* session keys in isolated runs so already-qualified keys are not double-prefixed (for example agent:main:main no longer becomes agent:main:agent:main:main). Landed from contributor PR #27333 by @MaheshBhushan. (#27289, #27282)
-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 channels..accounts.default before writing the new account so the original account keeps working without duplicated account values at channel root; openclaw doctor --fix now repairs previously mixed channel account shapes the same way. (#27334) thanks @gumadeiras.
-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.
-CI/Windows: shard the Windows checks-windows test lane into two matrix jobs and honor explicit shard index overrides in scripts/test-parallel.mjs to reduce CI critical-path wall time. (#27234) Thanks @joshavant.
-
-View full changelog
-]]>
-
-
\ No newline at end of file
diff --git a/apps/android/README.md b/apps/android/README.md
index f10c7fcede4c..50704e63d0b1 100644
--- a/apps/android/README.md
+++ b/apps/android/README.md
@@ -156,8 +156,8 @@ pnpm openclaw gateway --port 18789 --verbose
3) Approve pairing (on the gateway machine):
```bash
-openclaw nodes pending
-openclaw nodes approve
+openclaw devices list
+openclaw devices approve
```
More details: `docs/platforms/android.md`.
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index 0f0a78d51d62..9f714a643045 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -22,7 +22,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 202603010
- versionName = "2026.3.1"
+ versionName = "2026.3.2"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt
index 87572b37ad84..67241ef2ef74 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CameraCaptureManager.kt
@@ -33,10 +33,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withContext
-import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.contentOrNull
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.concurrent.Executor
@@ -101,7 +98,7 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
- val params = parseParamsObject(paramsJson)
+ val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val quality = (parseQuality(params) ?: 0.95).coerceIn(0.1, 1.0)
val maxWidth = parseMaxWidth(params) ?: 1600
@@ -167,7 +164,7 @@ class CameraCaptureManager(private val context: Context) {
withContext(Dispatchers.Main) {
ensureCameraPermission()
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
- val params = parseParamsObject(paramsJson)
+ val params = parseJsonParamsObject(paramsJson)
val facing = parseFacing(params) ?: "front"
val durationMs = (parseDurationMs(params) ?: 3_000).coerceIn(200, 60_000)
val includeAudio = parseIncludeAudio(params) ?: true
@@ -293,20 +290,8 @@ class CameraCaptureManager(private val context: Context) {
return rotated
}
- private fun parseParamsObject(paramsJson: String?): JsonObject? {
- if (paramsJson.isNullOrBlank()) return null
- return try {
- Json.parseToJsonElement(paramsJson).asObjectOrNull()
- } catch (_: Throwable) {
- null
- }
- }
-
- private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
- params?.get(key) as? JsonPrimitive
-
private fun parseFacing(params: JsonObject?): String? {
- val value = readPrimitive(params, "facing")?.contentOrNull?.trim()?.lowercase() ?: return null
+ val value = parseJsonString(params, "facing")?.trim()?.lowercase() ?: return null
return when (value) {
"front", "back" -> value
else -> null
@@ -314,31 +299,21 @@ class CameraCaptureManager(private val context: Context) {
}
private fun parseQuality(params: JsonObject?): Double? =
- readPrimitive(params, "quality")?.contentOrNull?.toDoubleOrNull()
+ parseJsonDouble(params, "quality")
private fun parseMaxWidth(params: JsonObject?): Int? =
- readPrimitive(params, "maxWidth")
- ?.contentOrNull
- ?.toIntOrNull()
+ parseJsonInt(params, "maxWidth")
?.takeIf { it > 0 }
private fun parseDurationMs(params: JsonObject?): Int? =
- readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
+ parseJsonInt(params, "durationMs")
private fun parseDeviceId(params: JsonObject?): String? =
- readPrimitive(params, "deviceId")
- ?.contentOrNull
+ parseJsonString(params, "deviceId")
?.trim()
?.takeIf { it.isNotEmpty() }
- private fun parseIncludeAudio(params: JsonObject?): Boolean? {
- val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
- return when (value) {
- "true" -> true
- "false" -> false
- else -> null
- }
- }
+ private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun Context.mainExecutor(): Executor = ContextCompat.getMainExecutor(this)
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt
index d0747ee32b00..a051bb91c3bc 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt
@@ -44,6 +44,14 @@ class CanvasController {
return (q * 100.0).toInt().coerceIn(1, 100)
}
+ private fun Bitmap.scaleForMaxWidth(maxWidth: Int?): Bitmap {
+ if (maxWidth == null || maxWidth <= 0 || width <= maxWidth) {
+ return this
+ }
+ val scaledHeight = (height.toDouble() * (maxWidth.toDouble() / width.toDouble())).toInt().coerceAtLeast(1)
+ return scale(maxWidth, scaledHeight)
+ }
+
fun attach(webView: WebView) {
this.webView = webView
reload()
@@ -148,13 +156,7 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
- val scaled =
- if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
- val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
- bmp.scale(maxWidth, h)
- } else {
- bmp
- }
+ val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
@@ -165,13 +167,7 @@ class CanvasController {
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
- val scaled =
- if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) {
- val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1)
- bmp.scale(maxWidth, h)
- } else {
- bmp
- }
+ val scaled = bmp.scaleForMaxWidth(maxWidth)
val out = ByteArrayOutputStream()
val (compressFormat, compressQuality) =
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt
index 6fb01a463ea1..2f706b7a6b27 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt
@@ -248,30 +248,37 @@ private object SystemContactsDataSource : ContactsDataSource {
}
private fun loadPhones(resolver: ContentResolver, contactId: Long): List {
- val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER)
- resolver.query(
- ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
- projection,
- "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?",
- arrayOf(contactId.toString()),
- null,
- ).use { cursor ->
- if (cursor == null) return emptyList()
- val out = LinkedHashSet()
- while (cursor.moveToNext()) {
- val value = cursor.getString(0)?.trim().orEmpty()
- if (value.isNotEmpty()) out += value
- }
- return out.toList()
- }
+ return queryContactValues(
+ resolver = resolver,
+ contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
+ valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER,
+ contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
+ contactId = contactId,
+ )
}
private fun loadEmails(resolver: ContentResolver, contactId: Long): List {
- val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS)
+ return queryContactValues(
+ resolver = resolver,
+ contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI,
+ valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS,
+ contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID,
+ contactId = contactId,
+ )
+ }
+
+ private fun queryContactValues(
+ resolver: ContentResolver,
+ contentUri: android.net.Uri,
+ valueColumn: String,
+ contactIdColumn: String,
+ contactId: Long,
+ ): List {
+ val projection = arrayOf(valueColumn)
resolver.query(
- ContactsContract.CommonDataKinds.Email.CONTENT_URI,
+ contentUri,
projection,
- "${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?",
+ "$contactIdColumn=?",
arrayOf(contactId.toString()),
null,
).use { cursor ->
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt
index 4a2ce7a9a780..30522b6d7556 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt
@@ -8,6 +8,7 @@ import android.content.Context
import android.content.Intent
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
+import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@@ -33,6 +34,21 @@ data class DeviceNotificationEntry(
val isClearable: Boolean,
)
+internal fun DeviceNotificationEntry.toJsonObject(): JsonObject {
+ return buildJsonObject {
+ put("key", JsonPrimitive(key))
+ put("packageName", JsonPrimitive(packageName))
+ put("postTimeMs", JsonPrimitive(postTimeMs))
+ put("isOngoing", JsonPrimitive(isOngoing))
+ put("isClearable", JsonPrimitive(isClearable))
+ title?.let { put("title", JsonPrimitive(it)) }
+ text?.let { put("text", JsonPrimitive(it)) }
+ subText?.let { put("subText", JsonPrimitive(it)) }
+ category?.let { put("category", JsonPrimitive(it)) }
+ channelId?.let { put("channelId", JsonPrimitive(it)) }
+ }
+}
+
data class DeviceNotificationSnapshot(
val enabled: Boolean,
val connected: Boolean,
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
index 8e6552edfbb1..36b89eb2ec8a 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt
@@ -10,7 +10,6 @@ import ai.openclaw.android.protocol.OpenClawDeviceCommand
import ai.openclaw.android.protocol.OpenClawLocationCommand
import ai.openclaw.android.protocol.OpenClawMotionCommand
import ai.openclaw.android.protocol.OpenClawNotificationsCommand
-import ai.openclaw.android.protocol.OpenClawPhotosCommand
import ai.openclaw.android.protocol.OpenClawScreenCommand
import ai.openclaw.android.protocol.OpenClawSmsCommand
import ai.openclaw.android.protocol.OpenClawSystemCommand
@@ -146,7 +145,9 @@ class InvokeDispatcher(
OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson)
// Photos command
- OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson)
+ ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(
+ paramsJson,
+ )
// Contacts command
OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson)
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt
index c3f463174a4e..5ba58c238603 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NodeUtils.kt
@@ -1,10 +1,12 @@
package ai.openclaw.android.node
import ai.openclaw.android.gateway.parseInvokeErrorFromThrowable
+import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
@@ -21,6 +23,35 @@ fun String.toJsonString(): String {
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
+fun parseJsonParamsObject(paramsJson: String?): JsonObject? {
+ if (paramsJson.isNullOrBlank()) return null
+ return try {
+ Json.parseToJsonElement(paramsJson).asObjectOrNull()
+ } catch (_: Throwable) {
+ null
+ }
+}
+
+fun readJsonPrimitive(params: JsonObject?, key: String): JsonPrimitive? = params?.get(key) as? JsonPrimitive
+
+fun parseJsonInt(params: JsonObject?, key: String): Int? =
+ readJsonPrimitive(params, key)?.contentOrNull?.toIntOrNull()
+
+fun parseJsonDouble(params: JsonObject?, key: String): Double? =
+ readJsonPrimitive(params, key)?.contentOrNull?.toDoubleOrNull()
+
+fun parseJsonString(params: JsonObject?, key: String): String? =
+ readJsonPrimitive(params, key)?.contentOrNull
+
+fun parseJsonBooleanFlag(params: JsonObject?, key: String): Boolean? {
+ val value = readJsonPrimitive(params, key)?.contentOrNull?.trim()?.lowercase() ?: return null
+ return when (value) {
+ "true" -> true
+ "false" -> false
+ else -> null
+ }
+}
+
fun JsonElement?.asStringOrNull(): String? =
when (this) {
is JsonNull -> null
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt
index 8195ab848473..755b20513b4c 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt
@@ -131,20 +131,7 @@ class NotificationsHandler private constructor(
put(
"notifications",
JsonArray(
- snapshot.notifications.map { entry ->
- buildJsonObject {
- put("key", JsonPrimitive(entry.key))
- put("packageName", JsonPrimitive(entry.packageName))
- put("postTimeMs", JsonPrimitive(entry.postTimeMs))
- put("isOngoing", JsonPrimitive(entry.isOngoing))
- put("isClearable", JsonPrimitive(entry.isClearable))
- entry.title?.let { put("title", JsonPrimitive(it)) }
- entry.text?.let { put("text", JsonPrimitive(it)) }
- entry.subText?.let { put("subText", JsonPrimitive(it)) }
- entry.category?.let { put("category", JsonPrimitive(it)) }
- entry.channelId?.let { put("channelId", JsonPrimitive(it)) }
- }
- },
+ snapshot.notifications.map { entry -> entry.toJsonObject() },
),
)
}.toString()
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt
index 98a3e4d95934..bb06d1200e40 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ScreenRecordManager.kt
@@ -10,10 +10,7 @@ import ai.openclaw.android.ScreenCaptureRequester
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
-import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.JsonPrimitive
-import kotlinx.serialization.json.contentOrNull
import java.io.File
import kotlin.math.roundToInt
@@ -39,7 +36,7 @@ class ScreenRecordManager(private val context: Context) {
"SCREEN_PERMISSION_REQUIRED: grant Screen Recording permission",
)
- val params = parseParamsObject(paramsJson)
+ val params = parseJsonParamsObject(paramsJson)
val durationMs = (parseDurationMs(params) ?: 10_000).coerceIn(250, 60_000)
val fps = (parseFps(params) ?: 10.0).coerceIn(1.0, 60.0)
val fpsInt = fps.roundToInt().coerceIn(1, 60)
@@ -146,38 +143,19 @@ class ScreenRecordManager(private val context: Context) {
}
}
- private fun parseParamsObject(paramsJson: String?): JsonObject? {
- if (paramsJson.isNullOrBlank()) return null
- return try {
- Json.parseToJsonElement(paramsJson).asObjectOrNull()
- } catch (_: Throwable) {
- null
- }
- }
-
- private fun readPrimitive(params: JsonObject?, key: String): JsonPrimitive? =
- params?.get(key) as? JsonPrimitive
-
private fun parseDurationMs(params: JsonObject?): Int? =
- readPrimitive(params, "durationMs")?.contentOrNull?.toIntOrNull()
+ parseJsonInt(params, "durationMs")
private fun parseFps(params: JsonObject?): Double? =
- readPrimitive(params, "fps")?.contentOrNull?.toDoubleOrNull()
+ parseJsonDouble(params, "fps")
private fun parseScreenIndex(params: JsonObject?): Int? =
- readPrimitive(params, "screenIndex")?.contentOrNull?.toIntOrNull()
-
- private fun parseIncludeAudio(params: JsonObject?): Boolean? {
- val value = readPrimitive(params, "includeAudio")?.contentOrNull?.trim()?.lowercase()
- return when (value) {
- "true" -> true
- "false" -> false
- else -> null
- }
- }
+ parseJsonInt(params, "screenIndex")
+
+ private fun parseIncludeAudio(params: JsonObject?): Boolean? = parseJsonBooleanFlag(params, "includeAudio")
private fun parseString(params: JsonObject?, key: String): String? =
- readPrimitive(params, key)?.contentOrNull
+ parseJsonString(params, key)
private fun estimateBitrate(width: Int, height: Int, fps: Int): Int {
val pixels = width.toLong() * height.toLong()
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt
new file mode 100644
index 000000000000..c54b80b6e84d
--- /dev/null
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt
@@ -0,0 +1,42 @@
+package ai.openclaw.android.ui.chat
+
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+internal data class Base64ImageState(
+ val image: ImageBitmap?,
+ val failed: Boolean,
+)
+
+@Composable
+internal fun rememberBase64ImageState(base64: String): Base64ImageState {
+ var image by remember(base64) { mutableStateOf(null) }
+ var failed by remember(base64) { mutableStateOf(false) }
+
+ LaunchedEffect(base64) {
+ failed = false
+ image =
+ withContext(Dispatchers.Default) {
+ try {
+ val bytes = Base64.decode(base64, Base64.DEFAULT)
+ val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
+ bitmap.asImageBitmap()
+ } catch (_: Throwable) {
+ null
+ }
+ }
+ if (image == null) failed = true
+ }
+
+ return Base64ImageState(image = image, failed = failed)
+}
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt
index e121212529a9..6b5fd6d8dbde 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt
@@ -1,7 +1,5 @@
package ai.openclaw.android.ui.chat
-import android.graphics.BitmapFactory
-import android.util.Base64
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -20,15 +18,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
@@ -47,8 +40,6 @@ import ai.openclaw.android.ui.mobileCaption1
import ai.openclaw.android.ui.mobileCodeBg
import ai.openclaw.android.ui.mobileCodeText
import ai.openclaw.android.ui.mobileTextSecondary
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
import org.commonmark.Extension
import org.commonmark.ext.autolink.AutolinkExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
@@ -555,23 +546,8 @@ private data class ParsedDataImage(
@Composable
private fun InlineBase64Image(base64: String, mimeType: String?) {
- var image by remember(base64) { mutableStateOf(null) }
- var failed by remember(base64) { mutableStateOf(false) }
-
- LaunchedEffect(base64) {
- failed = false
- image =
- withContext(Dispatchers.Default) {
- try {
- val bytes = Base64.decode(base64, Base64.DEFAULT)
- val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
- bitmap.asImageBitmap()
- } catch (_: Throwable) {
- null
- }
- }
- if (image == null) failed = true
- }
+ val imageState = rememberBase64ImageState(base64)
+ val image = imageState.image
if (image != null) {
Image(
@@ -580,7 +556,7 @@ private fun InlineBase64Image(base64: String, mimeType: String?) {
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
- } else if (failed) {
+ } else if (imageState.failed) {
Text(
text = "Image unavailable",
modifier = Modifier.padding(vertical = 2.dp),
diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt
index 3f4250c3dbbb..9ba5540f2d90 100644
--- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt
@@ -1,7 +1,5 @@
package ai.openclaw.android.ui.chat
-import android.graphics.BitmapFactory
-import android.util.Base64
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@@ -16,16 +14,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
@@ -51,8 +44,6 @@ import ai.openclaw.android.ui.mobileTextSecondary
import ai.openclaw.android.ui.mobileWarning
import ai.openclaw.android.ui.mobileWarningSoft
import java.util.Locale
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
private data class ChatBubbleStyle(
val alignEnd: Boolean,
@@ -241,23 +232,8 @@ private fun roleLabel(role: String): String {
@Composable
private fun ChatBase64Image(base64: String, mimeType: String?) {
- var image by remember(base64) { mutableStateOf(null) }
- var failed by remember(base64) { mutableStateOf(false) }
-
- LaunchedEffect(base64) {
- failed = false
- image =
- withContext(Dispatchers.Default) {
- try {
- val bytes = Base64.decode(base64, Base64.DEFAULT)
- val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
- bitmap.asImageBitmap()
- } catch (_: Throwable) {
- null
- }
- }
- if (image == null) failed = true
- }
+ val imageState = rememberBase64ImageState(base64)
+ val image = imageState.image
if (image != null) {
Surface(
@@ -273,7 +249,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
modifier = Modifier.fillMaxWidth(),
)
}
- } else if (failed) {
+ } else if (imageState.failed) {
Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary)
}
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt
index 8271d395a7d4..03930ee2a8bb 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/gateway/GatewaySessionInvokeTest.kt
@@ -3,12 +3,14 @@ package ai.openclaw.android.gateway
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Response
@@ -27,6 +29,10 @@ import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import java.util.concurrent.atomic.AtomicReference
+private const val TEST_TIMEOUT_MS = 8_000L
+private const val CONNECT_CHALLENGE_FRAME =
+ """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
+
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
private val tokens = mutableMapOf()
@@ -37,334 +43,150 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
}
}
+private data class NodeHarness(
+ val session: GatewaySession,
+ val sessionJob: Job,
+)
+
+private data class InvokeScenarioResult(
+ val request: GatewaySession.InvokeRequest,
+ val resultParams: JsonObject,
+)
+
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class GatewaySessionInvokeTest {
@Test
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
- val json = Json { ignoreUnknownKeys = true }
- val connected = CompletableDeferred()
- val invokeRequest = CompletableDeferred()
- val invokeResultParams = CompletableDeferred()
val handshakeOrigin = AtomicReference(null)
- val lastDisconnect = AtomicReference("")
- val server =
- MockWebServer().apply {
- dispatcher =
- object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse {
- handshakeOrigin.compareAndSet(null, request.getHeader("Origin"))
- return MockResponse().withWebSocketUpgrade(
- object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- webSocket.send(
- """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
- )
- }
-
- override fun onMessage(webSocket: WebSocket, text: String) {
- val frame = json.parseToJsonElement(text).jsonObject
- if (frame["type"]?.jsonPrimitive?.content != "req") return
- val id = frame["id"]?.jsonPrimitive?.content ?: return
- val method = frame["method"]?.jsonPrimitive?.content ?: return
- when (method) {
- "connect" -> {
- webSocket.send(
- """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
- )
- webSocket.send(
- """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
- )
- }
- "node.invoke.result" -> {
- if (!invokeResultParams.isCompleted) {
- invokeResultParams.complete(frame["params"]?.toString().orEmpty())
- }
- webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
- webSocket.close(1000, "done")
- }
- }
- }
- },
- )
- }
- }
- start()
+ val result =
+ runInvokeScenario(
+ invokeEventFrame =
+ """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-1","nodeId":"node-1","command":"debug.ping","params":{"ping":"pong"},"timeoutMs":5000}}""",
+ onHandshake = { request -> handshakeOrigin.compareAndSet(null, request.getHeader("Origin")) },
+ ) {
+ GatewaySession.InvokeResult.ok("""{"handled":true}""")
}
- val app = RuntimeEnvironment.getApplication()
- val sessionJob = SupervisorJob()
- val deviceAuthStore = InMemoryDeviceAuthStore()
- val session =
- GatewaySession(
- scope = CoroutineScope(sessionJob + Dispatchers.Default),
- identityStore = DeviceIdentityStore(app),
- deviceAuthStore = deviceAuthStore,
- onConnected = { _, _, _ ->
- if (!connected.isCompleted) connected.complete(Unit)
- },
- onDisconnected = { message ->
- lastDisconnect.set(message)
- },
- onEvent = { _, _ -> },
- onInvoke = { req ->
- if (!invokeRequest.isCompleted) invokeRequest.complete(req)
- GatewaySession.InvokeResult.ok("""{"handled":true}""")
- },
- )
+ assertEquals("invoke-1", result.request.id)
+ assertEquals("node-1", result.request.nodeId)
+ assertEquals("debug.ping", result.request.command)
+ assertEquals("""{"ping":"pong"}""", result.request.paramsJson)
+ assertNull(handshakeOrigin.get())
+ assertEquals("invoke-1", result.resultParams["id"]?.jsonPrimitive?.content)
+ assertEquals("node-1", result.resultParams["nodeId"]?.jsonPrimitive?.content)
+ assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
+ assertEquals(
+ true,
+ result.resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
+ )
+ }
- try {
- session.connect(
- endpoint =
- GatewayEndpoint(
- stableId = "manual|127.0.0.1|${server.port}",
- name = "test",
- host = "127.0.0.1",
- port = server.port,
- tlsEnabled = false,
- ),
- token = "test-token",
- password = null,
- options =
- GatewayConnectOptions(
- role = "node",
- scopes = listOf("node:invoke"),
- caps = emptyList(),
- commands = emptyList(),
- permissions = emptyMap(),
- client =
- GatewayClientInfo(
- id = "openclaw-android-test",
- displayName = "Android Test",
- version = "1.0.0-test",
- platform = "android",
- mode = "node",
- instanceId = "android-test-instance",
- deviceFamily = "android",
- modelIdentifier = "test",
- ),
- ),
- tls = null,
- )
+ @Test
+ fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
+ val result =
+ runInvokeScenario(
+ invokeEventFrame =
+ """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
+ ) {
+ GatewaySession.InvokeResult.ok("""{"handled":true}""")
+ }
- val connectedWithinTimeout = withTimeoutOrNull(8_000) {
- connected.await()
- true
- } == true
- if (!connectedWithinTimeout) {
- throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
+ assertEquals("invoke-2", result.request.id)
+ assertEquals("node-2", result.request.nodeId)
+ assertEquals("debug.raw", result.request.command)
+ assertEquals("""{"raw":true}""", result.request.paramsJson)
+ assertEquals("invoke-2", result.resultParams["id"]?.jsonPrimitive?.content)
+ assertEquals("node-2", result.resultParams["nodeId"]?.jsonPrimitive?.content)
+ assertEquals(true, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
+ }
+
+ @Test
+ fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
+ val result =
+ runInvokeScenario(
+ invokeEventFrame =
+ """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
+ ) {
+ throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
}
- val req = withTimeout(8_000) { invokeRequest.await() }
- val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
- val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
- assertEquals("invoke-1", req.id)
- assertEquals("node-1", req.nodeId)
- assertEquals("debug.ping", req.command)
- assertEquals("""{"ping":"pong"}""", req.paramsJson)
- assertNull(handshakeOrigin.get())
- assertEquals("invoke-1", resultParams["id"]?.jsonPrimitive?.content)
- assertEquals("node-1", resultParams["nodeId"]?.jsonPrimitive?.content)
- assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
- assertEquals(
- true,
- resultParams["payload"]?.jsonObject?.get("handled")?.jsonPrimitive?.content?.toBooleanStrict(),
- )
- } finally {
- session.disconnect()
- sessionJob.cancelAndJoin()
- server.shutdown()
- }
+ assertEquals("invoke-3", result.resultParams["id"]?.jsonPrimitive?.content)
+ assertEquals("node-3", result.resultParams["nodeId"]?.jsonPrimitive?.content)
+ assertEquals(false, result.resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
+ assertEquals(
+ "CAMERA_PERMISSION_REQUIRED",
+ result.resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
+ )
+ assertEquals(
+ "grant Camera permission",
+ result.resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
+ )
}
@Test
- fun nodeInvokeRequest_usesParamsJsonWhenProvided() = runBlocking {
- val json = Json { ignoreUnknownKeys = true }
+ fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
+ val json = testJson()
val connected = CompletableDeferred()
- val invokeRequest = CompletableDeferred()
- val invokeResultParams = CompletableDeferred()
+ val refreshRequestParams = CompletableDeferred()
val lastDisconnect = AtomicReference("")
- val server =
- MockWebServer().apply {
- dispatcher =
- object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse {
- return MockResponse().withWebSocketUpgrade(
- object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- webSocket.send(
- """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
- )
- }
- override fun onMessage(webSocket: WebSocket, text: String) {
- val frame = json.parseToJsonElement(text).jsonObject
- if (frame["type"]?.jsonPrimitive?.content != "req") return
- val id = frame["id"]?.jsonPrimitive?.content ?: return
- val method = frame["method"]?.jsonPrimitive?.content ?: return
- when (method) {
- "connect" -> {
- webSocket.send(
- """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
- )
- webSocket.send(
- """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-2","nodeId":"node-2","command":"debug.raw","paramsJSON":"{\"raw\":true}","params":{"ignored":1},"timeoutMs":5000}}""",
- )
- }
- "node.invoke.result" -> {
- if (!invokeResultParams.isCompleted) {
- invokeResultParams.complete(frame["params"]?.toString().orEmpty())
- }
- webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
- webSocket.close(1000, "done")
- }
- }
- }
- },
- )
+ val server =
+ startGatewayServer(json) { webSocket, id, method, frame ->
+ when (method) {
+ "connect" -> {
+ webSocket.send(connectResponseFrame(id, canvasHostUrl = "http://127.0.0.1/__openclaw__/cap/old-cap"))
+ }
+ "node.canvas.capability.refresh" -> {
+ if (!refreshRequestParams.isCompleted) {
+ refreshRequestParams.complete(frame["params"]?.toString())
}
+ webSocket.send(
+ """{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
+ )
+ webSocket.close(1000, "done")
}
- start()
+ }
}
- val app = RuntimeEnvironment.getApplication()
- val sessionJob = SupervisorJob()
- val deviceAuthStore = InMemoryDeviceAuthStore()
- val session =
- GatewaySession(
- scope = CoroutineScope(sessionJob + Dispatchers.Default),
- identityStore = DeviceIdentityStore(app),
- deviceAuthStore = deviceAuthStore,
- onConnected = { _, _, _ ->
- if (!connected.isCompleted) connected.complete(Unit)
- },
- onDisconnected = { message ->
- lastDisconnect.set(message)
- },
- onEvent = { _, _ -> },
- onInvoke = { req ->
- if (!invokeRequest.isCompleted) invokeRequest.complete(req)
- GatewaySession.InvokeResult.ok("""{"handled":true}""")
- },
- )
+ val harness =
+ createNodeHarness(
+ connected = connected,
+ lastDisconnect = lastDisconnect,
+ ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
- session.connect(
- endpoint =
- GatewayEndpoint(
- stableId = "manual|127.0.0.1|${server.port}",
- name = "test",
- host = "127.0.0.1",
- port = server.port,
- tlsEnabled = false,
- ),
- token = "test-token",
- password = null,
- options =
- GatewayConnectOptions(
- role = "node",
- scopes = listOf("node:invoke"),
- caps = emptyList(),
- commands = emptyList(),
- permissions = emptyMap(),
- client =
- GatewayClientInfo(
- id = "openclaw-android-test",
- displayName = "Android Test",
- version = "1.0.0-test",
- platform = "android",
- mode = "node",
- instanceId = "android-test-instance",
- deviceFamily = "android",
- modelIdentifier = "test",
- ),
- ),
- tls = null,
- )
+ connectNodeSession(harness.session, server.port)
+ awaitConnectedOrThrow(connected, lastDisconnect, server)
- val connectedWithinTimeout = withTimeoutOrNull(8_000) {
- connected.await()
- true
- } == true
- if (!connectedWithinTimeout) {
- throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
- }
-
- val req = withTimeout(8_000) { invokeRequest.await() }
- val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
- val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
+ val refreshed = harness.session.refreshNodeCanvasCapability(timeoutMs = TEST_TIMEOUT_MS)
+ val refreshParamsJson = withTimeout(TEST_TIMEOUT_MS) { refreshRequestParams.await() }
- assertEquals("invoke-2", req.id)
- assertEquals("node-2", req.nodeId)
- assertEquals("debug.raw", req.command)
- assertEquals("""{"raw":true}""", req.paramsJson)
- assertEquals("invoke-2", resultParams["id"]?.jsonPrimitive?.content)
- assertEquals("node-2", resultParams["nodeId"]?.jsonPrimitive?.content)
- assertEquals(true, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
+ assertEquals(true, refreshed)
+ assertEquals("{}", refreshParamsJson)
+ assertEquals(
+ "http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
+ harness.session.currentCanvasHostUrl(),
+ )
} finally {
- session.disconnect()
- sessionJob.cancelAndJoin()
- server.shutdown()
+ shutdownHarness(harness, server)
}
}
- @Test
- fun nodeInvokeRequest_mapsCodePrefixedErrorsIntoInvokeResult() = runBlocking {
- val json = Json { ignoreUnknownKeys = true }
- val connected = CompletableDeferred()
- val invokeResultParams = CompletableDeferred()
- val lastDisconnect = AtomicReference("")
- val server =
- MockWebServer().apply {
- dispatcher =
- object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse {
- return MockResponse().withWebSocketUpgrade(
- object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- webSocket.send(
- """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
- )
- }
-
- override fun onMessage(webSocket: WebSocket, text: String) {
- val frame = json.parseToJsonElement(text).jsonObject
- if (frame["type"]?.jsonPrimitive?.content != "req") return
- val id = frame["id"]?.jsonPrimitive?.content ?: return
- val method = frame["method"]?.jsonPrimitive?.content ?: return
- when (method) {
- "connect" -> {
- webSocket.send(
- """{"type":"res","id":"$id","ok":true,"payload":{"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
- )
- webSocket.send(
- """{"type":"event","event":"node.invoke.request","payload":{"id":"invoke-3","nodeId":"node-3","command":"camera.snap","params":{"facing":"front"},"timeoutMs":5000}}""",
- )
- }
- "node.invoke.result" -> {
- if (!invokeResultParams.isCompleted) {
- invokeResultParams.complete(frame["params"]?.toString().orEmpty())
- }
- webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
- webSocket.close(1000, "done")
- }
- }
- }
- },
- )
- }
- }
- start()
- }
+ private fun testJson(): Json = Json { ignoreUnknownKeys = true }
+ private fun createNodeHarness(
+ connected: CompletableDeferred,
+ lastDisconnect: AtomicReference,
+ onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
+ ): NodeHarness {
val app = RuntimeEnvironment.getApplication()
val sessionJob = SupervisorJob()
- val deviceAuthStore = InMemoryDeviceAuthStore()
val session =
GatewaySession(
scope = CoroutineScope(sessionJob + Dispatchers.Default),
identityStore = DeviceIdentityStore(app),
- deviceAuthStore = deviceAuthStore,
+ deviceAuthStore = InMemoryDeviceAuthStore(),
onConnected = { _, _, _ ->
if (!connected.isCompleted) connected.complete(Unit)
},
@@ -372,195 +194,150 @@ class GatewaySessionInvokeTest {
lastDisconnect.set(message)
},
onEvent = { _, _ -> },
- onInvoke = {
- throw IllegalStateException("CAMERA_PERMISSION_REQUIRED: grant Camera permission")
- },
+ onInvoke = onInvoke,
)
- try {
- session.connect(
- endpoint =
- GatewayEndpoint(
- stableId = "manual|127.0.0.1|${server.port}",
- name = "test",
- host = "127.0.0.1",
- port = server.port,
- tlsEnabled = false,
- ),
- token = "test-token",
- password = null,
- options =
- GatewayConnectOptions(
- role = "node",
- scopes = listOf("node:invoke"),
- caps = emptyList(),
- commands = emptyList(),
- permissions = emptyMap(),
- client =
- GatewayClientInfo(
- id = "openclaw-android-test",
- displayName = "Android Test",
- version = "1.0.0-test",
- platform = "android",
- mode = "node",
- instanceId = "android-test-instance",
- deviceFamily = "android",
- modelIdentifier = "test",
- ),
- ),
- tls = null,
- )
+ return NodeHarness(session = session, sessionJob = sessionJob)
+ }
- val connectedWithinTimeout = withTimeoutOrNull(8_000) {
+ private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
+ session.connect(
+ endpoint =
+ GatewayEndpoint(
+ stableId = "manual|127.0.0.1|$port",
+ name = "test",
+ host = "127.0.0.1",
+ port = port,
+ tlsEnabled = false,
+ ),
+ token = "test-token",
+ password = null,
+ options =
+ GatewayConnectOptions(
+ role = "node",
+ scopes = listOf("node:invoke"),
+ caps = emptyList(),
+ commands = emptyList(),
+ permissions = emptyMap(),
+ client =
+ GatewayClientInfo(
+ id = "openclaw-android-test",
+ displayName = "Android Test",
+ version = "1.0.0-test",
+ platform = "android",
+ mode = "node",
+ instanceId = "android-test-instance",
+ deviceFamily = "android",
+ modelIdentifier = "test",
+ ),
+ ),
+ tls = null,
+ )
+ }
+
+ private suspend fun awaitConnectedOrThrow(
+ connected: CompletableDeferred,
+ lastDisconnect: AtomicReference,
+ server: MockWebServer,
+ ) {
+ val connectedWithinTimeout =
+ withTimeoutOrNull(TEST_TIMEOUT_MS) {
connected.await()
true
} == true
- if (!connectedWithinTimeout) {
- throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
- }
-
- val resultParamsJson = withTimeout(8_000) { invokeResultParams.await() }
- val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
-
- assertEquals("invoke-3", resultParams["id"]?.jsonPrimitive?.content)
- assertEquals("node-3", resultParams["nodeId"]?.jsonPrimitive?.content)
- assertEquals(false, resultParams["ok"]?.jsonPrimitive?.content?.toBooleanStrict())
- assertEquals(
- "CAMERA_PERMISSION_REQUIRED",
- resultParams["error"]?.jsonObject?.get("code")?.jsonPrimitive?.content,
- )
- assertEquals(
- "grant Camera permission",
- resultParams["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content,
- )
- } finally {
- session.disconnect()
- sessionJob.cancelAndJoin()
- server.shutdown()
+ if (!connectedWithinTimeout) {
+ throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
}
}
- @Test
- fun refreshNodeCanvasCapability_sendsObjectParamsAndUpdatesScopedUrl() = runBlocking {
- val json = Json { ignoreUnknownKeys = true }
+ private suspend fun shutdownHarness(harness: NodeHarness, server: MockWebServer) {
+ harness.session.disconnect()
+ harness.sessionJob.cancelAndJoin()
+ server.shutdown()
+ }
+
+ private suspend fun runInvokeScenario(
+ invokeEventFrame: String,
+ onHandshake: ((RecordedRequest) -> Unit)? = null,
+ onInvoke: (GatewaySession.InvokeRequest) -> GatewaySession.InvokeResult,
+ ): InvokeScenarioResult {
+ val json = testJson()
val connected = CompletableDeferred()
- val refreshRequestParams = CompletableDeferred()
+ val invokeRequest = CompletableDeferred()
+ val invokeResultParams = CompletableDeferred()
val lastDisconnect = AtomicReference("")
val server =
- MockWebServer().apply {
- dispatcher =
- object : Dispatcher() {
- override fun dispatch(request: RecordedRequest): MockResponse {
- return MockResponse().withWebSocketUpgrade(
- object : WebSocketListener() {
- override fun onOpen(webSocket: WebSocket, response: Response) {
- webSocket.send(
- """{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}""",
- )
- }
-
- override fun onMessage(webSocket: WebSocket, text: String) {
- val frame = json.parseToJsonElement(text).jsonObject
- if (frame["type"]?.jsonPrimitive?.content != "req") return
- val id = frame["id"]?.jsonPrimitive?.content ?: return
- val method = frame["method"]?.jsonPrimitive?.content ?: return
- when (method) {
- "connect" -> {
- webSocket.send(
- """{"type":"res","id":"$id","ok":true,"payload":{"canvasHostUrl":"http://127.0.0.1/__openclaw__/cap/old-cap","snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}""",
- )
- }
- "node.canvas.capability.refresh" -> {
- if (!refreshRequestParams.isCompleted) {
- refreshRequestParams.complete(frame["params"]?.toString())
- }
- webSocket.send(
- """{"type":"res","id":"$id","ok":true,"payload":{"canvasCapability":"new-cap"}}""",
- )
- webSocket.close(1000, "done")
- }
- }
- }
- },
- )
+ startGatewayServer(
+ json = json,
+ onHandshake = onHandshake,
+ ) { webSocket, id, method, frame ->
+ when (method) {
+ "connect" -> {
+ webSocket.send(connectResponseFrame(id))
+ webSocket.send(invokeEventFrame)
+ }
+ "node.invoke.result" -> {
+ if (!invokeResultParams.isCompleted) {
+ invokeResultParams.complete(frame["params"]?.toString().orEmpty())
}
+ webSocket.send("""{"type":"res","id":"$id","ok":true,"payload":{"ok":true}}""")
+ webSocket.close(1000, "done")
}
- start()
+ }
+ }
+ val harness =
+ createNodeHarness(
+ connected = connected,
+ lastDisconnect = lastDisconnect,
+ ) { req ->
+ if (!invokeRequest.isCompleted) invokeRequest.complete(req)
+ onInvoke(req)
}
-
- val app = RuntimeEnvironment.getApplication()
- val sessionJob = SupervisorJob()
- val deviceAuthStore = InMemoryDeviceAuthStore()
- val session =
- GatewaySession(
- scope = CoroutineScope(sessionJob + Dispatchers.Default),
- identityStore = DeviceIdentityStore(app),
- deviceAuthStore = deviceAuthStore,
- onConnected = { _, _, _ ->
- if (!connected.isCompleted) connected.complete(Unit)
- },
- onDisconnected = { message ->
- lastDisconnect.set(message)
- },
- onEvent = { _, _ -> },
- onInvoke = { GatewaySession.InvokeResult.ok("""{"handled":true}""") },
- )
try {
- session.connect(
- endpoint =
- GatewayEndpoint(
- stableId = "manual|127.0.0.1|${server.port}",
- name = "test",
- host = "127.0.0.1",
- port = server.port,
- tlsEnabled = false,
- ),
- token = "test-token",
- password = null,
- options =
- GatewayConnectOptions(
- role = "node",
- scopes = listOf("node:invoke"),
- caps = emptyList(),
- commands = emptyList(),
- permissions = emptyMap(),
- client =
- GatewayClientInfo(
- id = "openclaw-android-test",
- displayName = "Android Test",
- version = "1.0.0-test",
- platform = "android",
- mode = "node",
- instanceId = "android-test-instance",
- deviceFamily = "android",
- modelIdentifier = "test",
- ),
- ),
- tls = null,
- )
+ connectNodeSession(harness.session, server.port)
+ awaitConnectedOrThrow(connected, lastDisconnect, server)
+ val request = withTimeout(TEST_TIMEOUT_MS) { invokeRequest.await() }
+ val resultParamsJson = withTimeout(TEST_TIMEOUT_MS) { invokeResultParams.await() }
+ val resultParams = json.parseToJsonElement(resultParamsJson).jsonObject
+ return InvokeScenarioResult(request = request, resultParams = resultParams)
+ } finally {
+ shutdownHarness(harness, server)
+ }
+ }
- val connectedWithinTimeout = withTimeoutOrNull(8_000) {
- connected.await()
- true
- } == true
- if (!connectedWithinTimeout) {
- throw AssertionError("never connected; lastDisconnect=${lastDisconnect.get()}; requests=${server.requestCount}")
- }
+ private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
+ val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
+ return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
+ }
- val refreshed = session.refreshNodeCanvasCapability(timeoutMs = 8_000)
- val refreshParamsJson = withTimeout(8_000) { refreshRequestParams.await() }
+ private fun startGatewayServer(
+ json: Json,
+ onHandshake: ((RecordedRequest) -> Unit)? = null,
+ onRequestFrame: (webSocket: WebSocket, id: String, method: String, frame: JsonObject) -> Unit,
+ ): MockWebServer =
+ MockWebServer().apply {
+ dispatcher =
+ object : Dispatcher() {
+ override fun dispatch(request: RecordedRequest): MockResponse {
+ onHandshake?.invoke(request)
+ return MockResponse().withWebSocketUpgrade(
+ object : WebSocketListener() {
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ webSocket.send(CONNECT_CHALLENGE_FRAME)
+ }
- assertEquals(true, refreshed)
- assertEquals("{}", refreshParamsJson)
- assertEquals(
- "http://127.0.0.1:${server.port}/__openclaw__/cap/new-cap",
- session.currentCanvasHostUrl(),
- )
- } finally {
- session.disconnect()
- sessionJob.cancelAndJoin()
- server.shutdown()
+ override fun onMessage(webSocket: WebSocket, text: String) {
+ val frame = json.parseToJsonElement(text).jsonObject
+ if (frame["type"]?.jsonPrimitive?.content != "req") return
+ val id = frame["id"]?.jsonPrimitive?.content ?: return
+ val method = frame["method"]?.jsonPrimitive?.content ?: return
+ onRequestFrame(webSocket, id, method, frame)
+ }
+ },
+ )
+ }
+ }
+ start()
}
- }
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt
index a2d8e0919fd5..ca236da7d460 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/CalendarHandlerTest.kt
@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-@RunWith(RobolectricTestRunner::class)
-class CalendarHandlerTest {
+class CalendarHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleCalendarEvents_requiresPermission() {
val handler = CalendarHandler.forTesting(appContext(), FakeCalendarDataSource(canRead = false))
@@ -83,8 +79,6 @@ class CalendarHandlerTest {
assertFalse(result.ok)
assertEquals("CALENDAR_NOT_FOUND", result.error?.code)
}
-
- private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeCalendarDataSource(
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt
index 61af8e0df668..39242dc9f82a 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/ContactsHandlerTest.kt
@@ -9,12 +9,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-@RunWith(RobolectricTestRunner::class)
-class ContactsHandlerTest {
+class ContactsHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleContactsSearch_requiresReadPermission() {
val handler = ContactsHandler.forTesting(appContext(), FakeContactsDataSource(canRead = false))
@@ -92,8 +88,6 @@ class ContactsHandlerTest {
assertEquals("Grace Hopper", contact.getValue("displayName").jsonPrimitive.content)
assertEquals(1, source.addCalls)
}
-
- private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeContactsDataSource(
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt
index bd3dced03e52..0b8548ab215c 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/InvokeCommandRegistryTest.kt
@@ -16,144 +16,106 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class InvokeCommandRegistryTest {
+ private val coreCapabilities =
+ setOf(
+ OpenClawCapability.Canvas.rawValue,
+ OpenClawCapability.Screen.rawValue,
+ OpenClawCapability.Device.rawValue,
+ OpenClawCapability.Notifications.rawValue,
+ OpenClawCapability.System.rawValue,
+ OpenClawCapability.AppUpdate.rawValue,
+ OpenClawCapability.Photos.rawValue,
+ OpenClawCapability.Contacts.rawValue,
+ OpenClawCapability.Calendar.rawValue,
+ )
+
+ private val optionalCapabilities =
+ setOf(
+ OpenClawCapability.Camera.rawValue,
+ OpenClawCapability.Location.rawValue,
+ OpenClawCapability.Sms.rawValue,
+ OpenClawCapability.VoiceWake.rawValue,
+ OpenClawCapability.Motion.rawValue,
+ )
+
+ private val coreCommands =
+ setOf(
+ OpenClawDeviceCommand.Status.rawValue,
+ OpenClawDeviceCommand.Info.rawValue,
+ OpenClawDeviceCommand.Permissions.rawValue,
+ OpenClawDeviceCommand.Health.rawValue,
+ OpenClawNotificationsCommand.List.rawValue,
+ OpenClawNotificationsCommand.Actions.rawValue,
+ OpenClawSystemCommand.Notify.rawValue,
+ OpenClawPhotosCommand.Latest.rawValue,
+ OpenClawContactsCommand.Search.rawValue,
+ OpenClawContactsCommand.Add.rawValue,
+ OpenClawCalendarCommand.Events.rawValue,
+ OpenClawCalendarCommand.Add.rawValue,
+ "app.update",
+ )
+
+ private val optionalCommands =
+ setOf(
+ OpenClawCameraCommand.Snap.rawValue,
+ OpenClawCameraCommand.Clip.rawValue,
+ OpenClawCameraCommand.List.rawValue,
+ OpenClawLocationCommand.Get.rawValue,
+ OpenClawMotionCommand.Activity.rawValue,
+ OpenClawMotionCommand.Pedometer.rawValue,
+ OpenClawSmsCommand.Send.rawValue,
+ )
+
+ private val debugCommands = setOf("debug.logs", "debug.ed25519")
+
@Test
fun advertisedCapabilities_respectsFeatureAvailability() {
- val capabilities =
- InvokeCommandRegistry.advertisedCapabilities(
- NodeRuntimeFlags(
- cameraEnabled = false,
- locationEnabled = false,
- smsAvailable = false,
- voiceWakeEnabled = false,
- motionActivityAvailable = false,
- motionPedometerAvailable = false,
- debugBuild = false,
- ),
- )
+ val capabilities = InvokeCommandRegistry.advertisedCapabilities(defaultFlags())
- assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
- assertFalse(capabilities.contains(OpenClawCapability.Camera.rawValue))
- assertFalse(capabilities.contains(OpenClawCapability.Location.rawValue))
- assertFalse(capabilities.contains(OpenClawCapability.Sms.rawValue))
- assertFalse(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
- assertFalse(capabilities.contains(OpenClawCapability.Motion.rawValue))
+ assertContainsAll(capabilities, coreCapabilities)
+ assertMissingAll(capabilities, optionalCapabilities)
}
@Test
fun advertisedCapabilities_includesFeatureCapabilitiesWhenEnabled() {
val capabilities =
InvokeCommandRegistry.advertisedCapabilities(
- NodeRuntimeFlags(
+ defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
voiceWakeEnabled = true,
motionActivityAvailable = true,
motionPedometerAvailable = true,
- debugBuild = false,
),
)
- assertTrue(capabilities.contains(OpenClawCapability.Canvas.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Screen.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Device.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Notifications.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.System.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.AppUpdate.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Camera.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Location.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Sms.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.VoiceWake.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Photos.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Contacts.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Calendar.rawValue))
- assertTrue(capabilities.contains(OpenClawCapability.Motion.rawValue))
+ assertContainsAll(capabilities, coreCapabilities + optionalCapabilities)
}
@Test
fun advertisedCommands_respectsFeatureAvailability() {
- val commands =
- InvokeCommandRegistry.advertisedCommands(
- NodeRuntimeFlags(
- cameraEnabled = false,
- locationEnabled = false,
- smsAvailable = false,
- voiceWakeEnabled = false,
- motionActivityAvailable = false,
- motionPedometerAvailable = false,
- debugBuild = false,
- ),
- )
+ val commands = InvokeCommandRegistry.advertisedCommands(defaultFlags())
- assertFalse(commands.contains(OpenClawCameraCommand.Snap.rawValue))
- assertFalse(commands.contains(OpenClawCameraCommand.Clip.rawValue))
- assertFalse(commands.contains(OpenClawCameraCommand.List.rawValue))
- assertFalse(commands.contains(OpenClawLocationCommand.Get.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
- assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
- assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
- assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
- assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
- assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
- assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
- assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
- assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
- assertFalse(commands.contains(OpenClawMotionCommand.Activity.rawValue))
- assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
- assertFalse(commands.contains(OpenClawSmsCommand.Send.rawValue))
- assertFalse(commands.contains("debug.logs"))
- assertFalse(commands.contains("debug.ed25519"))
- assertTrue(commands.contains("app.update"))
+ assertContainsAll(commands, coreCommands)
+ assertMissingAll(commands, optionalCommands + debugCommands)
}
@Test
fun advertisedCommands_includesFeatureCommandsWhenEnabled() {
val commands =
InvokeCommandRegistry.advertisedCommands(
- NodeRuntimeFlags(
+ defaultFlags(
cameraEnabled = true,
locationEnabled = true,
smsAvailable = true,
- voiceWakeEnabled = false,
motionActivityAvailable = true,
motionPedometerAvailable = true,
debugBuild = true,
),
)
- assertTrue(commands.contains(OpenClawCameraCommand.Snap.rawValue))
- assertTrue(commands.contains(OpenClawCameraCommand.Clip.rawValue))
- assertTrue(commands.contains(OpenClawCameraCommand.List.rawValue))
- assertTrue(commands.contains(OpenClawLocationCommand.Get.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Status.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Info.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Permissions.rawValue))
- assertTrue(commands.contains(OpenClawDeviceCommand.Health.rawValue))
- assertTrue(commands.contains(OpenClawNotificationsCommand.List.rawValue))
- assertTrue(commands.contains(OpenClawNotificationsCommand.Actions.rawValue))
- assertTrue(commands.contains(OpenClawSystemCommand.Notify.rawValue))
- assertTrue(commands.contains(OpenClawPhotosCommand.Latest.rawValue))
- assertTrue(commands.contains(OpenClawContactsCommand.Search.rawValue))
- assertTrue(commands.contains(OpenClawContactsCommand.Add.rawValue))
- assertTrue(commands.contains(OpenClawCalendarCommand.Events.rawValue))
- assertTrue(commands.contains(OpenClawCalendarCommand.Add.rawValue))
- assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
- assertTrue(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
- assertTrue(commands.contains(OpenClawSmsCommand.Send.rawValue))
- assertTrue(commands.contains("debug.logs"))
- assertTrue(commands.contains("debug.ed25519"))
- assertTrue(commands.contains("app.update"))
+ assertContainsAll(commands, coreCommands + optionalCommands + debugCommands)
}
@Test
@@ -174,4 +136,31 @@ class InvokeCommandRegistryTest {
assertTrue(commands.contains(OpenClawMotionCommand.Activity.rawValue))
assertFalse(commands.contains(OpenClawMotionCommand.Pedometer.rawValue))
}
+
+ private fun defaultFlags(
+ cameraEnabled: Boolean = false,
+ locationEnabled: Boolean = false,
+ smsAvailable: Boolean = false,
+ voiceWakeEnabled: Boolean = false,
+ motionActivityAvailable: Boolean = false,
+ motionPedometerAvailable: Boolean = false,
+ debugBuild: Boolean = false,
+ ): NodeRuntimeFlags =
+ NodeRuntimeFlags(
+ cameraEnabled = cameraEnabled,
+ locationEnabled = locationEnabled,
+ smsAvailable = smsAvailable,
+ voiceWakeEnabled = voiceWakeEnabled,
+ motionActivityAvailable = motionActivityAvailable,
+ motionPedometerAvailable = motionPedometerAvailable,
+ debugBuild = debugBuild,
+ )
+
+ private fun assertContainsAll(actual: List, expected: Set) {
+ expected.forEach { value -> assertTrue(actual.contains(value)) }
+ }
+
+ private fun assertMissingAll(actual: List, forbidden: Set) {
+ forbidden.forEach { value -> assertFalse(actual.contains(value)) }
+ }
}
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt
index 1a0fb0c0bd61..c7eff170a0cd 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/MotionHandlerTest.kt
@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-@RunWith(RobolectricTestRunner::class)
-class MotionHandlerTest {
+class MotionHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleMotionActivity_requiresPermission() =
runTest {
@@ -86,8 +82,6 @@ class MotionHandlerTest {
assertEquals("MOTION_UNAVAILABLE", result.error?.code)
assertTrue(result.error?.message?.contains("PEDOMETER_RANGE_UNAVAILABLE") == true)
}
-
- private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakeMotionDataSource(
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt
new file mode 100644
index 000000000000..8138c7039fd6
--- /dev/null
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/NodeHandlerRobolectricTest.kt
@@ -0,0 +1,11 @@
+package ai.openclaw.android.node
+
+import android.content.Context
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+
+@RunWith(RobolectricTestRunner::class)
+abstract class NodeHandlerRobolectricTest {
+ protected fun appContext(): Context = RuntimeEnvironment.getApplication()
+}
diff --git a/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt
index c9596452c5be..707d886d74f2 100644
--- a/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt
+++ b/apps/android/app/src/test/java/ai/openclaw/android/node/PhotosHandlerTest.kt
@@ -10,12 +10,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.RuntimeEnvironment
-@RunWith(RobolectricTestRunner::class)
-class PhotosHandlerTest {
+class PhotosHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handlePhotosLatest_requiresPermission() {
val handler = PhotosHandler.forTesting(appContext(), FakePhotosDataSource(hasPermission = false))
@@ -63,8 +59,6 @@ class PhotosHandlerTest {
assertEquals("jpeg", first.getValue("format").jsonPrimitive.content)
assertEquals(640, first.getValue("width").jsonPrimitive.int)
}
-
- private fun appContext(): Context = RuntimeEnvironment.getApplication()
}
private class FakePhotosDataSource(
diff --git a/apps/ios/ShareExtension/Info.plist b/apps/ios/ShareExtension/Info.plist
index e793541d08d2..6e1113cf2056 100644
--- a/apps/ios/ShareExtension/Info.plist
+++ b/apps/ios/ShareExtension/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 2026.3.1
+ 2026.3.2
CFBundleVersion
20260301
NSExtension
diff --git a/apps/ios/Sources/Camera/CameraController.swift b/apps/ios/Sources/Camera/CameraController.swift
index 1e9c10bc44c9..115f36346dcf 100644
--- a/apps/ios/Sources/Camera/CameraController.swift
+++ b/apps/ios/Sources/Camera/CameraController.swift
@@ -52,46 +52,27 @@ actor CameraController {
try await self.ensureAccess(for: .video)
- let session = AVCaptureSession()
- session.sessionPreset = .photo
-
- guard let device = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
- throw CameraError.cameraUnavailable
- }
-
- let input = try AVCaptureDeviceInput(device: device)
- guard session.canAddInput(input) else {
- throw CameraError.captureFailed("Failed to add camera input")
- }
- session.addInput(input)
-
- let output = AVCapturePhotoOutput()
- guard session.canAddOutput(output) else {
- throw CameraError.captureFailed("Failed to add photo output")
- }
- session.addOutput(output)
- output.maxPhotoQualityPrioritization = .quality
+ let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
+ preferFrontCamera: facing == .front,
+ deviceId: params.deviceId,
+ pickCamera: { preferFrontCamera, deviceId in
+ Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
+ },
+ cameraUnavailableError: CameraError.cameraUnavailable,
+ mapSetupError: { setupError in
+ CameraError.captureFailed(setupError.localizedDescription)
+ })
+ let session = prepared.session
+ let output = prepared.output
session.startRunning()
defer { session.stopRunning() }
- await Self.warmUpCaptureSession()
+ await CameraCapturePipelineSupport.warmUpCaptureSession()
await Self.sleepDelayMs(delayMs)
- let settings: AVCapturePhotoSettings = {
- if output.availablePhotoCodecTypes.contains(.jpeg) {
- return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
- }
- return AVCapturePhotoSettings()
- }()
- settings.photoQualityPrioritization = .quality
-
- var delegate: PhotoCaptureDelegate?
- let rawData: Data = try await withCheckedThrowingContinuation { cont in
- let d = PhotoCaptureDelegate(cont)
- delegate = d
- output.capturePhoto(with: settings, delegate: d)
+ let rawData = try await CameraCapturePipelineSupport.capturePhotoData(output: output) { continuation in
+ PhotoCaptureDelegate(continuation)
}
- withExtendedLifetime(delegate) {}
let res = try PhotoCapture.transcodeJPEGForGateway(
rawData: rawData,
@@ -121,63 +102,37 @@ actor CameraController {
try await self.ensureAccess(for: .audio)
}
- let session = AVCaptureSession()
- session.sessionPreset = .high
-
- guard let camera = Self.pickCamera(facing: facing, deviceId: params.deviceId) else {
- throw CameraError.cameraUnavailable
- }
- let cameraInput = try AVCaptureDeviceInput(device: camera)
- guard session.canAddInput(cameraInput) else {
- throw CameraError.captureFailed("Failed to add camera input")
- }
- session.addInput(cameraInput)
-
- if includeAudio {
- guard let mic = AVCaptureDevice.default(for: .audio) else {
- throw CameraError.microphoneUnavailable
- }
- let micInput = try AVCaptureDeviceInput(device: mic)
- if session.canAddInput(micInput) {
- session.addInput(micInput)
- } else {
- throw CameraError.captureFailed("Failed to add microphone input")
- }
- }
-
- let output = AVCaptureMovieFileOutput()
- guard session.canAddOutput(output) else {
- throw CameraError.captureFailed("Failed to add movie output")
- }
- session.addOutput(output)
- output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
-
- session.startRunning()
- defer { session.stopRunning() }
- await Self.warmUpCaptureSession()
-
let movURL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
let mp4URL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
-
defer {
try? FileManager().removeItem(at: movURL)
try? FileManager().removeItem(at: mp4URL)
}
- var delegate: MovieFileDelegate?
- let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
- let d = MovieFileDelegate(cont)
- delegate = d
- output.startRecording(to: movURL, recordingDelegate: d)
- }
- withExtendedLifetime(delegate) {}
-
- // Transcode .mov -> .mp4 for easier downstream handling.
- try await Self.exportToMP4(inputURL: recordedURL, outputURL: mp4URL)
-
- let data = try Data(contentsOf: mp4URL)
+ let data = try await CameraCapturePipelineSupport.withWarmMovieSession(
+ preferFrontCamera: facing == .front,
+ deviceId: params.deviceId,
+ includeAudio: includeAudio,
+ durationMs: durationMs,
+ pickCamera: { preferFrontCamera, deviceId in
+ Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
+ },
+ cameraUnavailableError: CameraError.cameraUnavailable,
+ mapSetupError: Self.mapMovieSetupError,
+ operation: { output in
+ var delegate: MovieFileDelegate?
+ let recordedURL: URL = try await withCheckedThrowingContinuation { cont in
+ let d = MovieFileDelegate(cont)
+ delegate = d
+ output.startRecording(to: movURL, recordingDelegate: d)
+ }
+ withExtendedLifetime(delegate) {}
+ // 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(),
@@ -196,22 +151,7 @@ actor CameraController {
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
- let status = AVCaptureDevice.authorizationStatus(for: mediaType)
- switch status {
- case .authorized:
- return
- case .notDetermined:
- let ok = await withCheckedContinuation(isolation: nil) { cont in
- AVCaptureDevice.requestAccess(for: mediaType) { granted in
- cont.resume(returning: granted)
- }
- }
- if !ok {
- throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
- }
- case .denied, .restricted:
- throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
- @unknown default:
+ if !(await CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
@@ -233,12 +173,15 @@ actor CameraController {
return AVCaptureDevice.default(for: .video)
}
+ private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
+ CameraCapturePipelineSupport.mapMovieSetupError(
+ setupError,
+ microphoneUnavailableError: .microphoneUnavailable,
+ captureFailed: { .captureFailed($0) })
+ }
+
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
- switch position {
- case .front: "front"
- case .back: "back"
- default: "unspecified"
- }
+ CameraCapturePipelineSupport.positionLabel(position)
}
private nonisolated static func discoverVideoDevices() -> [AVCaptureDevice] {
@@ -307,11 +250,6 @@ actor CameraController {
}
}
- private nonisolated static func warmUpCaptureSession() async {
- // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
- try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
- }
-
private nonisolated static func sleepDelayMs(_ delayMs: Int) async {
guard delayMs > 0 else { return }
let maxDelayMs = 10 * 1000
diff --git a/apps/ios/Sources/Contacts/ContactsService.swift b/apps/ios/Sources/Contacts/ContactsService.swift
index db203d070f14..efe89f8a218c 100644
--- a/apps/ios/Sources/Contacts/ContactsService.swift
+++ b/apps/ios/Sources/Contacts/ContactsService.swift
@@ -15,14 +15,7 @@ final class ContactsService: ContactsServicing {
}
func search(params: OpenClawContactsSearchParams) async throws -> OpenClawContactsSearchPayload {
- let store = CNContactStore()
- let status = CNContactStore.authorizationStatus(for: .contacts)
- let authorized = await Self.ensureAuthorization(store: store, status: status)
- guard authorized else {
- throw NSError(domain: "Contacts", code: 1, userInfo: [
- NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
- ])
- }
+ let store = try await Self.authorizedStore()
let limit = max(1, min(params.limit ?? 25, 200))
@@ -47,14 +40,7 @@ final class ContactsService: ContactsServicing {
}
func add(params: OpenClawContactsAddParams) async throws -> OpenClawContactsAddPayload {
- let store = CNContactStore()
- let status = CNContactStore.authorizationStatus(for: .contacts)
- let authorized = await Self.ensureAuthorization(store: store, status: status)
- guard authorized else {
- throw NSError(domain: "Contacts", code: 1, userInfo: [
- NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
- ])
- }
+ let store = try await Self.authorizedStore()
let givenName = params.givenName?.trimmingCharacters(in: .whitespacesAndNewlines)
let familyName = params.familyName?.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -127,6 +113,18 @@ final class ContactsService: ContactsServicing {
}
}
+ private static func authorizedStore() async throws -> CNContactStore {
+ let store = CNContactStore()
+ let status = CNContactStore.authorizationStatus(for: .contacts)
+ let authorized = await Self.ensureAuthorization(store: store, status: status)
+ guard authorized else {
+ throw NSError(domain: "Contacts", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
+ ])
+ }
+ return store
+ }
+
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
(values ?? [])
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
diff --git a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift
index 04bb220d5f36..1090904f0b9b 100644
--- a/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift
+++ b/apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift
@@ -53,23 +53,17 @@ final class GatewayDiscoveryModel {
self.appendDebugLog("start()")
for domain in OpenClawBonjour.gatewayServiceDomains {
- let params = NWParameters.tcp
- params.includePeerToPeer = true
- let browser = NWBrowser(
- for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain),
- using: params)
-
- browser.stateUpdateHandler = { [weak self] state in
- Task { @MainActor in
+ let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
+ serviceType: OpenClawBonjour.gatewayServiceType,
+ domain: domain,
+ queueLabelPrefix: "ai.openclaw.ios.gateway-discovery",
+ onState: { [weak self] state in
guard let self else { return }
self.statesByDomain[domain] = state
self.updateStatusText()
self.appendDebugLog("state[\(domain)]: \(Self.prettyState(state))")
- }
- }
-
- browser.browseResultsChangedHandler = { [weak self] results, _ in
- Task { @MainActor in
+ },
+ onResults: { [weak self] results in
guard let self else { return }
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
switch result.endpoint {
@@ -98,13 +92,10 @@ final class GatewayDiscoveryModel {
}
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
-
self.recomputeGateways()
- }
- }
+ })
self.browsers[domain] = browser
- browser.start(queue: DispatchQueue(label: "ai.openclaw.ios.gateway-discovery.\(domain)"))
}
}
diff --git a/apps/ios/Sources/Gateway/GatewayServiceResolver.swift b/apps/ios/Sources/Gateway/GatewayServiceResolver.swift
index 882a4e7d05a0..dab3b4787cf4 100644
--- a/apps/ios/Sources/Gateway/GatewayServiceResolver.swift
+++ b/apps/ios/Sources/Gateway/GatewayServiceResolver.swift
@@ -1,4 +1,5 @@
import Foundation
+import OpenClawKit
// NetService-based resolver for Bonjour services.
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
@@ -20,8 +21,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
}
func start(timeout: TimeInterval = 2.0) {
- self.service.schedule(in: .main, forMode: .common)
- self.service.resolve(withTimeout: timeout)
+ BonjourServiceResolverSupport.start(self.service, timeout: timeout)
}
func netServiceDidResolveAddress(_ sender: NetService) {
@@ -47,9 +47,6 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
}
private static func normalizeHost(_ raw: String?) -> String? {
- let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmed.isEmpty { return nil }
- return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
+ BonjourServiceResolverSupport.normalizeHost(raw)
}
}
-
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index b05fc179d79a..86556e094b0a 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.1
+ 2026.3.2
CFBundleURLTypes
diff --git a/apps/ios/Sources/Location/LocationService.swift b/apps/ios/Sources/Location/LocationService.swift
index f1f0f69ed7fa..f974e84cfd45 100644
--- a/apps/ios/Sources/Location/LocationService.swift
+++ b/apps/ios/Sources/Location/LocationService.swift
@@ -3,7 +3,7 @@ import CoreLocation
import Foundation
@MainActor
-final class LocationService: NSObject, CLLocationManagerDelegate {
+final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
enum Error: Swift.Error {
case timeout
case unavailable
@@ -17,21 +17,18 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
private var isMonitoringSignificantChanges = false
- override init() {
- super.init()
- self.manager.delegate = self
- self.manager.desiredAccuracy = kCLLocationAccuracyBest
+ var locationManager: CLLocationManager {
+ self.manager
}
- func authorizationStatus() -> CLAuthorizationStatus {
- self.manager.authorizationStatus
+ var locationRequestContinuation: CheckedContinuation? {
+ get { self.locationContinuation }
+ set { self.locationContinuation = newValue }
}
- func accuracyAuthorization() -> CLAccuracyAuthorization {
- if #available(iOS 14.0, *) {
- return self.manager.accuracyAuthorization
- }
- return .fullAccuracy
+ override init() {
+ super.init()
+ self.configureLocationManager()
}
func ensureAuthorization(mode: OpenClawLocationMode) async -> CLAuthorizationStatus {
@@ -62,26 +59,16 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
- let now = Date()
- if let maxAgeMs,
- let cached = self.manager.location,
- now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
- {
- return cached
- }
-
- self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
- let timeout = max(0, timeoutMs ?? 10000)
- return try await self.withTimeout(timeoutMs: timeout) {
- try await self.requestLocation()
- }
- }
-
- private func requestLocation() async throws -> CLLocation {
- try await withCheckedThrowingContinuation { cont in
- self.locationContinuation = cont
- self.manager.requestLocation()
- }
+ _ = params
+ return try await LocationCurrentRequest.resolve(
+ manager: self.manager,
+ desiredAccuracy: desiredAccuracy,
+ maxAgeMs: maxAgeMs,
+ timeoutMs: timeoutMs,
+ request: { try await self.requestLocationOnce() },
+ withTimeout: { timeoutMs, operation in
+ try await self.withTimeout(timeoutMs: timeoutMs, operation: operation)
+ })
}
private func awaitAuthorizationChange() async -> CLAuthorizationStatus {
@@ -97,24 +84,13 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
}
- private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
- switch accuracy {
- case .coarse:
- kCLLocationAccuracyKilometer
- case .balanced:
- kCLLocationAccuracyHundredMeters
- case .precise:
- kCLLocationAccuracyBest
- }
- }
-
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream
{
self.stopLocationUpdates()
- self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
+ self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
self.manager.pausesLocationUpdatesAutomatically = true
self.manager.allowsBackgroundLocationUpdates = true
diff --git a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift
index e8dce2cd30cf..922757a65553 100644
--- a/apps/ios/Sources/Model/NodeAppModel+Canvas.swift
+++ b/apps/ios/Sources/Model/NodeAppModel+Canvas.swift
@@ -1,5 +1,6 @@
import Foundation
import Network
+import OpenClawKit
import os
extension NodeAppModel {
@@ -11,24 +12,12 @@ extension NodeAppModel {
guard let raw = await self.gatewaySession.currentCanvasHostUrl() else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let base = URL(string: trimmed) else { return nil }
- if let host = base.host, Self.isLoopbackHost(host) {
+ if let host = base.host, LoopbackHost.isLoopback(host) {
return nil
}
return base.appendingPathComponent("__openclaw__/a2ui/").absoluteString + "?platform=ios"
}
- private static func isLoopbackHost(_ host: String) -> Bool {
- let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- if normalized.isEmpty { return true }
- if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
- return true
- }
- if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
- return true
- }
- return false
- }
-
func showA2UIOnConnectIfNeeded() async {
guard let a2uiUrl = await self.resolveA2UIHostURL() else {
await MainActor.run {
diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
index bf6c0ba2d187..b8b6e2677552 100644
--- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
+++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
@@ -41,15 +41,17 @@ private struct AutoDetectStep: View {
.foregroundStyle(.secondary)
}
- Section("Connection status") {
- ConnectionStatusBox(
- statusLines: self.connectionStatusLines(),
- secondaryLine: self.connectStatusText)
- }
+ gatewayConnectionStatusSection(
+ appModel: self.appModel,
+ gatewayController: self.gatewayController,
+ secondaryLine: self.connectStatusText)
Section {
Button("Retry") {
- self.resetConnectionState()
+ resetGatewayConnectionState(
+ appModel: self.appModel,
+ connectStatusText: &self.connectStatusText,
+ connectingGatewayID: &self.connectingGatewayID)
self.triggerAutoConnect()
}
.disabled(self.connectingGatewayID != nil)
@@ -94,15 +96,6 @@ private struct AutoDetectStep: View {
return nil
}
- private func connectionStatusLines() -> [String] {
- ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
- }
-
- private func resetConnectionState() {
- self.appModel.disconnectGateway()
- self.connectStatusText = nil
- self.connectingGatewayID = nil
- }
}
private struct ManualEntryStep: View {
@@ -162,11 +155,10 @@ private struct ManualEntryStep: View {
.autocorrectionDisabled()
}
- Section("Connection status") {
- ConnectionStatusBox(
- statusLines: self.connectionStatusLines(),
- secondaryLine: self.connectStatusText)
- }
+ gatewayConnectionStatusSection(
+ appModel: self.appModel,
+ gatewayController: self.gatewayController,
+ secondaryLine: self.connectStatusText)
Section {
Button {
@@ -185,7 +177,10 @@ private struct ManualEntryStep: View {
.disabled(self.connectingGatewayID != nil)
Button("Retry") {
- self.resetConnectionState()
+ resetGatewayConnectionState(
+ appModel: self.appModel,
+ connectStatusText: &self.connectStatusText,
+ connectingGatewayID: &self.connectingGatewayID)
self.resetManualForm()
}
.disabled(self.connectingGatewayID != nil)
@@ -237,16 +232,6 @@ private struct ManualEntryStep: View {
return Int(trimmed.filter { $0.isNumber })
}
- private func connectionStatusLines() -> [String] {
- ConnectionStatusBox.defaultLines(appModel: self.appModel, gatewayController: self.gatewayController)
- }
-
- private func resetConnectionState() {
- self.appModel.disconnectGateway()
- self.connectStatusText = nil
- self.connectingGatewayID = nil
- }
-
private func resetManualForm() {
self.setupCode = ""
self.setupStatusText = nil
@@ -317,6 +302,41 @@ private struct ManualEntryStep: View {
// (GatewaySetupCode) decode raw setup codes.
}
+@MainActor
+private func gatewayConnectionStatusLines(
+ appModel: NodeAppModel,
+ gatewayController: GatewayConnectionController) -> [String]
+{
+ ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
+}
+
+@MainActor
+private func resetGatewayConnectionState(
+ appModel: NodeAppModel,
+ connectStatusText: inout String?,
+ connectingGatewayID: inout String?)
+{
+ appModel.disconnectGateway()
+ connectStatusText = nil
+ connectingGatewayID = nil
+}
+
+@MainActor
+@ViewBuilder
+private func gatewayConnectionStatusSection(
+ appModel: NodeAppModel,
+ gatewayController: GatewayConnectionController,
+ secondaryLine: String?) -> some View
+{
+ Section("Connection status") {
+ ConnectionStatusBox(
+ statusLines: gatewayConnectionStatusLines(
+ appModel: appModel,
+ gatewayController: gatewayController),
+ secondaryLine: secondaryLine)
+ }
+}
+
private struct ConnectionStatusBox: View {
let statusLines: [String]
let secondaryLine: String?
diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
index b0dbdc136390..8a97b20e0c76 100644
--- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
+++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift
@@ -489,21 +489,7 @@ struct OnboardingWizardView: View {
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualTLS)
-
- Button {
- Task { await self.connectManual() }
- } label: {
- if self.connectingGatewayID == "manual" {
- HStack(spacing: 8) {
- ProgressView()
- .progressViewStyle(.circular)
- Text("Connecting…")
- }
- } else {
- Text("Connect")
- }
- }
- .disabled(!self.canConnectManual || self.connectingGatewayID != nil)
+ self.manualConnectButton
} header: {
Text("Developer Local")
} footer: {
@@ -631,22 +617,25 @@ struct OnboardingWizardView: View {
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
+ self.manualConnectButton
+ }
+ }
- Button {
- Task { await self.connectManual() }
- } label: {
- if self.connectingGatewayID == "manual" {
- HStack(spacing: 8) {
- ProgressView()
- .progressViewStyle(.circular)
- Text("Connecting…")
- }
- } else {
- Text("Connect")
+ private var manualConnectButton: some View {
+ Button {
+ Task { await self.connectManual() }
+ } label: {
+ if self.connectingGatewayID == "manual" {
+ HStack(spacing: 8) {
+ ProgressView()
+ .progressViewStyle(.circular)
+ Text("Connecting…")
}
+ } else {
+ Text("Connect")
}
- .disabled(!self.canConnectManual || self.connectingGatewayID != nil)
}
+ .disabled(!self.canConnectManual || self.connectingGatewayID != nil)
}
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift
index 27f7f5e02ca9..c94b1209f8d6 100644
--- a/apps/ios/Sources/OpenClawApp.swift
+++ b/apps/ios/Sources/OpenClawApp.swift
@@ -456,11 +456,7 @@ enum WatchPromptNotificationBridge {
) async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
center.add(request) { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: ())
- }
+ ThrowingContinuationSupport.resumeVoid(continuation, error: error)
}
}
}
diff --git a/apps/ios/Sources/RootCanvas.swift b/apps/ios/Sources/RootCanvas.swift
index dd0f389ed4d4..3fc62d7e859d 100644
--- a/apps/ios/Sources/RootCanvas.swift
+++ b/apps/ios/Sources/RootCanvas.swift
@@ -177,20 +177,7 @@ struct RootCanvas: View {
}
private var gatewayStatus: StatusPill.GatewayState {
- if self.appModel.gatewayServerName != nil { return .connected }
-
- let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
- if text.localizedCaseInsensitiveContains("connecting") ||
- text.localizedCaseInsensitiveContains("reconnecting")
- {
- return .connecting
- }
-
- if text.localizedCaseInsensitiveContains("error") {
- return .error
- }
-
- return .disconnected
+ GatewayStatusBuilder.build(appModel: self.appModel)
}
private func updateIdleTimer() {
@@ -343,82 +330,18 @@ private struct CanvasContent: View {
.transition(.move(edge: .top).combined(with: .opacity))
}
}
- .confirmationDialog(
- "Gateway",
+ .gatewayActionsDialog(
isPresented: self.$showGatewayActions,
- titleVisibility: .visible)
- {
- Button("Disconnect", role: .destructive) {
- self.appModel.disconnectGateway()
- }
- Button("Open Settings") {
- self.openSettings()
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Disconnect from the gateway?")
- }
+ onDisconnect: { self.appModel.disconnectGateway() },
+ onOpenSettings: { self.openSettings() })
}
private var statusActivity: StatusPill.Activity? {
- // Status pill owns transient activity state so it doesn't overlap the connection indicator.
- if self.appModel.isBackgrounded {
- return StatusPill.Activity(
- title: "Foreground required",
- systemImage: "exclamationmark.triangle.fill",
- tint: .orange)
- }
-
- let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
- let gatewayLower = gatewayStatus.lowercased()
- if gatewayLower.contains("repair") {
- return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
- }
- if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
- return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
- }
- // Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
-
- if self.appModel.screenRecordActive {
- return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
- }
-
- if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
- let systemImage: String
- let tint: Color?
- switch cameraHUDKind {
- case .photo:
- systemImage = "camera.fill"
- tint = nil
- case .recording:
- systemImage = "video.fill"
- tint = .red
- case .success:
- systemImage = "checkmark.circle.fill"
- tint = .green
- case .error:
- systemImage = "exclamationmark.triangle.fill"
- tint = .red
- }
- return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
- }
-
- if self.voiceWakeEnabled {
- let voiceStatus = self.appModel.voiceWake.statusText
- if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
- return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
- }
- if voiceStatus == "Paused" {
- // Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
- if self.appModel.talkMode.isEnabled {
- return nil
- }
- let suffix = self.appModel.isBackgrounded ? " (background)" : ""
- return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
- }
- }
-
- return nil
+ StatusActivityBuilder.build(
+ appModel: self.appModel,
+ voiceWakeEnabled: self.voiceWakeEnabled,
+ cameraHUDText: self.cameraHUDText,
+ cameraHUDKind: self.cameraHUDKind)
}
}
diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift
index 4733a4a30fcb..fb5176725884 100644
--- a/apps/ios/Sources/RootTabs.swift
+++ b/apps/ios/Sources/RootTabs.swift
@@ -70,38 +70,14 @@ struct RootTabs: View {
self.toastDismissTask?.cancel()
self.toastDismissTask = nil
}
- .confirmationDialog(
- "Gateway",
+ .gatewayActionsDialog(
isPresented: self.$showGatewayActions,
- titleVisibility: .visible)
- {
- Button("Disconnect", role: .destructive) {
- self.appModel.disconnectGateway()
- }
- Button("Open Settings") {
- self.selectedTab = 2
- }
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("Disconnect from the gateway?")
- }
+ onDisconnect: { self.appModel.disconnectGateway() },
+ onOpenSettings: { self.selectedTab = 2 })
}
private var gatewayStatus: StatusPill.GatewayState {
- if self.appModel.gatewayServerName != nil { return .connected }
-
- let text = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
- if text.localizedCaseInsensitiveContains("connecting") ||
- text.localizedCaseInsensitiveContains("reconnecting")
- {
- return .connecting
- }
-
- if text.localizedCaseInsensitiveContains("error") {
- return .error
- }
-
- return .disconnected
+ GatewayStatusBuilder.build(appModel: self.appModel)
}
private var statusActivity: StatusPill.Activity? {
diff --git a/apps/ios/Sources/Screen/ScreenController.swift b/apps/ios/Sources/Screen/ScreenController.swift
index 0045232362bd..5c9450335519 100644
--- a/apps/ios/Sources/Screen/ScreenController.swift
+++ b/apps/ios/Sources/Screen/ScreenController.swift
@@ -35,7 +35,7 @@ final class ScreenController {
if let url = URL(string: trimmed),
!url.isFileURL,
let host = url.host,
- Self.isLoopbackHost(host)
+ LoopbackHost.isLoopback(host)
{
// Never try to load loopback URLs from a remote gateway.
self.showDefaultCanvas()
@@ -87,25 +87,11 @@ final class ScreenController {
func applyDebugStatusIfNeeded() {
guard let webView = self.activeWebView else { return }
- let enabled = self.debugStatusEnabled
- let title = self.debugStatusTitle
- let subtitle = self.debugStatusSubtitle
- let js = """
- (() => {
- try {
- const api = globalThis.__openclaw;
- if (!api) return;
- if (typeof api.setDebugStatusEnabled === 'function') {
- api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
- }
- if (!\(enabled ? "true" : "false")) return;
- if (typeof api.setStatus === 'function') {
- api.setStatus(\(Self.jsValue(title)), \(Self.jsValue(subtitle)));
- }
- } catch (_) {}
- })()
- """
- webView.evaluateJavaScript(js) { _, _ in }
+ WebViewJavaScriptSupport.applyDebugStatus(
+ webView: webView,
+ enabled: self.debugStatusEnabled,
+ title: self.debugStatusTitle,
+ subtitle: self.debugStatusSubtitle)
}
func waitForA2UIReady(timeoutMs: Int) async -> Bool {
@@ -137,46 +123,11 @@ final class ScreenController {
NSLocalizedDescriptionKey: "web view unavailable",
])
}
- return try await withCheckedThrowingContinuation { cont in
- webView.evaluateJavaScript(javaScript) { result, error in
- if let error {
- cont.resume(throwing: error)
- return
- }
- if let result {
- cont.resume(returning: String(describing: result))
- } else {
- cont.resume(returning: "")
- }
- }
- }
+ return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
}
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
- let config = WKSnapshotConfiguration()
- if let maxWidth {
- config.snapshotWidth = NSNumber(value: Double(maxWidth))
- }
- guard let webView = self.activeWebView else {
- throw NSError(domain: "Screen", code: 3, userInfo: [
- NSLocalizedDescriptionKey: "web view unavailable",
- ])
- }
- let image: UIImage = try await withCheckedThrowingContinuation { cont in
- webView.takeSnapshot(with: config) { image, error in
- if let error {
- cont.resume(throwing: error)
- return
- }
- guard let image else {
- cont.resume(throwing: NSError(domain: "Screen", code: 2, userInfo: [
- NSLocalizedDescriptionKey: "snapshot failed",
- ]))
- return
- }
- cont.resume(returning: image)
- }
- }
+ let image = try await self.snapshotImage(maxWidth: maxWidth)
guard let data = image.pngData() else {
throw NSError(domain: "Screen", code: 1, userInfo: [
NSLocalizedDescriptionKey: "snapshot encode failed",
@@ -190,6 +141,25 @@ final class ScreenController {
format: OpenClawCanvasSnapshotFormat,
quality: Double? = nil) async throws -> String
{
+ let image = try await self.snapshotImage(maxWidth: maxWidth)
+
+ let data: Data?
+ switch format {
+ case .png:
+ data = image.pngData()
+ case .jpeg:
+ let q = (quality ?? 0.82).clamped(to: 0.1...1.0)
+ data = image.jpegData(compressionQuality: q)
+ }
+ guard let data else {
+ throw NSError(domain: "Screen", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "snapshot encode failed",
+ ])
+ }
+ return data.base64EncodedString()
+ }
+
+ private func snapshotImage(maxWidth: CGFloat?) async throws -> UIImage {
let config = WKSnapshotConfiguration()
if let maxWidth {
config.snapshotWidth = NSNumber(value: Double(maxWidth))
@@ -214,21 +184,7 @@ final class ScreenController {
cont.resume(returning: image)
}
}
-
- let data: Data?
- switch format {
- case .png:
- data = image.pngData()
- case .jpeg:
- let q = (quality ?? 0.82).clamped(to: 0.1...1.0)
- data = image.jpegData(compressionQuality: q)
- }
- guard let data else {
- throw NSError(domain: "Screen", code: 1, userInfo: [
- NSLocalizedDescriptionKey: "snapshot encode failed",
- ])
- }
- return data.base64EncodedString()
+ return image
}
func attachWebView(_ webView: WKWebView) {
@@ -258,17 +214,6 @@ final class ScreenController {
ext: "html",
subdirectory: "CanvasScaffold")
- private static func isLoopbackHost(_ host: String) -> Bool {
- let normalized = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- if normalized.isEmpty { return true }
- if normalized == "localhost" || normalized == "::1" || normalized == "0.0.0.0" {
- return true
- }
- if normalized == "127.0.0.1" || normalized.hasPrefix("127.") {
- return true
- }
- return false
- }
func isTrustedCanvasUIURL(_ url: URL) -> Bool {
guard url.isFileURL else { return false }
let std = url.standardizedFileURL
@@ -290,59 +235,8 @@ final class ScreenController {
scrollView.bounces = allowScroll
}
- private static func jsValue(_ value: String?) -> String {
- guard let value else { return "null" }
- if let data = try? JSONSerialization.data(withJSONObject: [value]),
- let encoded = String(data: data, encoding: .utf8),
- encoded.count >= 2
- {
- return String(encoded.dropFirst().dropLast())
- }
- return "null"
- }
-
func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
- return false
- }
- guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
- return false
- }
- if host == "localhost" { return true }
- if host.hasSuffix(".local") { return true }
- if host.hasSuffix(".ts.net") { return true }
- if host.hasSuffix(".tailscale.net") { return true }
- // Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
- if !host.contains("."), !host.contains(":") { return true }
- if let ipv4 = Self.parseIPv4(host) {
- return Self.isLocalNetworkIPv4(ipv4)
- }
- return false
- }
-
- private static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
- 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])
- }
-
- private static func isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
- let (a, b, _, _) = ip
- // 10.0.0.0/8
- if a == 10 { return true }
- // 172.16.0.0/12
- if a == 172, (16...31).contains(Int(b)) { return true }
- // 192.168.0.0/16
- if a == 192, b == 168 { return true }
- // 127.0.0.0/8
- if a == 127 { return true }
- // 169.254.0.0/16 (link-local)
- if a == 169, b == 254 { return true }
- // Tailscale: 100.64.0.0/10
- if a == 100, (64...127).contains(Int(b)) { return true }
- return false
+ LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
}
nonisolated static func parseA2UIActionBody(_ body: Any) -> [String: Any]? {
diff --git a/apps/ios/Sources/Screen/ScreenRecordService.swift b/apps/ios/Sources/Screen/ScreenRecordService.swift
index c353d86f22d7..4bea2724dcaa 100644
--- a/apps/ios/Sources/Screen/ScreenRecordService.swift
+++ b/apps/ios/Sources/Screen/ScreenRecordService.swift
@@ -1,4 +1,5 @@
import AVFoundation
+import OpenClawKit
import ReplayKit
final class ScreenRecordService: @unchecked Sendable {
@@ -84,8 +85,8 @@ final class ScreenRecordService: @unchecked Sendable {
throw ScreenRecordError.invalidScreenIndex(idx)
}
- let durationMs = Self.clampDurationMs(durationMs)
- let fps = Self.clampFps(fps)
+ let durationMs = CaptureRateLimits.clampDurationMs(durationMs)
+ let fps = CaptureRateLimits.clampFps(fps, maxFps: 30)
let fpsInt = Int32(fps.rounded())
let fpsValue = Double(fpsInt)
let includeAudio = includeAudio ?? true
@@ -319,16 +320,6 @@ final class ScreenRecordService: @unchecked Sendable {
}
}
- private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
- let v = ms ?? 10000
- return min(60000, max(250, v))
- }
-
- private nonisolated static func clampFps(_ fps: Double?) -> Double {
- let v = fps ?? 10
- if !v.isFinite { return 10 }
- return min(30, max(1, v))
- }
}
@MainActor
@@ -350,11 +341,11 @@ private func stopReplayKitCapture(_ completion: @escaping @Sendable (Error?) ->
#if DEBUG
extension ScreenRecordService {
nonisolated static func _test_clampDurationMs(_ ms: Int?) -> Int {
- self.clampDurationMs(ms)
+ CaptureRateLimits.clampDurationMs(ms)
}
nonisolated static func _test_clampFps(_ fps: Double?) -> Double {
- self.clampFps(fps)
+ CaptureRateLimits.clampFps(fps, maxFps: 30)
}
}
#endif
diff --git a/apps/ios/Sources/Status/GatewayActionsDialog.swift b/apps/ios/Sources/Status/GatewayActionsDialog.swift
new file mode 100644
index 000000000000..8c1ec42f3b83
--- /dev/null
+++ b/apps/ios/Sources/Status/GatewayActionsDialog.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+extension View {
+ func gatewayActionsDialog(
+ isPresented: Binding,
+ onDisconnect: @escaping () -> Void,
+ onOpenSettings: @escaping () -> Void) -> some View
+ {
+ self.confirmationDialog(
+ "Gateway",
+ isPresented: isPresented,
+ titleVisibility: .visible)
+ {
+ Button("Disconnect", role: .destructive) {
+ onDisconnect()
+ }
+ Button("Open Settings") {
+ onOpenSettings()
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text("Disconnect from the gateway?")
+ }
+ }
+}
diff --git a/apps/ios/Sources/Status/GatewayStatusBuilder.swift b/apps/ios/Sources/Status/GatewayStatusBuilder.swift
new file mode 100644
index 000000000000..dd15f586521e
--- /dev/null
+++ b/apps/ios/Sources/Status/GatewayStatusBuilder.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+enum GatewayStatusBuilder {
+ @MainActor
+ static func build(appModel: NodeAppModel) -> StatusPill.GatewayState {
+ if appModel.gatewayServerName != nil { return .connected }
+
+ let text = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if text.localizedCaseInsensitiveContains("connecting") ||
+ text.localizedCaseInsensitiveContains("reconnecting")
+ {
+ return .connecting
+ }
+
+ if text.localizedCaseInsensitiveContains("error") {
+ return .error
+ }
+
+ return .disconnected
+ }
+}
diff --git a/apps/ios/Sources/Status/StatusGlassCard.swift b/apps/ios/Sources/Status/StatusGlassCard.swift
new file mode 100644
index 000000000000..6ee9ae0e4030
--- /dev/null
+++ b/apps/ios/Sources/Status/StatusGlassCard.swift
@@ -0,0 +1,39 @@
+import SwiftUI
+
+private struct StatusGlassCardModifier: ViewModifier {
+ @Environment(\.colorSchemeContrast) private var contrast
+
+ let brighten: Bool
+ let verticalPadding: CGFloat
+ let horizontalPadding: CGFloat
+
+ func body(content: Content) -> some View {
+ content
+ .padding(.vertical, self.verticalPadding)
+ .padding(.horizontal, self.horizontalPadding)
+ .background {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .fill(.ultraThinMaterial)
+ .overlay {
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
+ .strokeBorder(
+ .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
+ lineWidth: self.contrast == .increased ? 1.0 : 0.5
+ )
+ }
+ .shadow(color: .black.opacity(0.25), radius: 12, y: 6)
+ }
+ }
+}
+
+extension View {
+ func statusGlassCard(brighten: Bool, verticalPadding: CGFloat, horizontalPadding: CGFloat = 12) -> some View {
+ self.modifier(
+ StatusGlassCardModifier(
+ brighten: brighten,
+ verticalPadding: verticalPadding,
+ horizontalPadding: horizontalPadding
+ )
+ )
+ }
+}
diff --git a/apps/ios/Sources/Status/StatusPill.swift b/apps/ios/Sources/Status/StatusPill.swift
index 8c0885fc516c..a723ce5eb39c 100644
--- a/apps/ios/Sources/Status/StatusPill.swift
+++ b/apps/ios/Sources/Status/StatusPill.swift
@@ -3,7 +3,6 @@ import SwiftUI
struct StatusPill: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
- @Environment(\.colorSchemeContrast) private var contrast
enum GatewayState: Equatable {
case connected
@@ -86,20 +85,7 @@ struct StatusPill: View {
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
- .padding(.vertical, 8)
- .padding(.horizontal, 12)
- .background {
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay {
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(
- .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
- lineWidth: self.contrast == .increased ? 1.0 : 0.5
- )
- }
- .shadow(color: .black.opacity(0.25), radius: 12, y: 6)
- }
+ .statusGlassCard(brighten: self.brighten, verticalPadding: 8)
}
.buttonStyle(.plain)
.accessibilityLabel("Connection Status")
diff --git a/apps/ios/Sources/Status/VoiceWakeToast.swift b/apps/ios/Sources/Status/VoiceWakeToast.swift
index ef6fc1295a76..251b2f5512a6 100644
--- a/apps/ios/Sources/Status/VoiceWakeToast.swift
+++ b/apps/ios/Sources/Status/VoiceWakeToast.swift
@@ -1,8 +1,6 @@
import SwiftUI
struct VoiceWakeToast: View {
- @Environment(\.colorSchemeContrast) private var contrast
-
var command: String
var brighten: Bool = false
@@ -18,20 +16,7 @@ struct VoiceWakeToast: View {
.lineLimit(1)
.truncationMode(.tail)
}
- .padding(.vertical, 10)
- .padding(.horizontal, 12)
- .background {
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .fill(.ultraThinMaterial)
- .overlay {
- RoundedRectangle(cornerRadius: 14, style: .continuous)
- .strokeBorder(
- .white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
- lineWidth: self.contrast == .increased ? 1.0 : 0.5
- )
- }
- .shadow(color: .black.opacity(0.25), radius: 12, y: 6)
- }
+ .statusGlassCard(brighten: self.brighten, verticalPadding: 10)
.accessibilityLabel("Voice Wake triggered")
.accessibilityValue("Command: \(self.command)")
}
diff --git a/apps/ios/Sources/Voice/VoiceWakeManager.swift b/apps/ios/Sources/Voice/VoiceWakeManager.swift
index 3a5b75859622..46174343bc82 100644
--- a/apps/ios/Sources/Voice/VoiceWakeManager.swift
+++ b/apps/ios/Sources/Voice/VoiceWakeManager.swift
@@ -216,22 +216,7 @@ final class VoiceWakeManager: NSObject {
self.isEnabled = false
self.isListening = false
self.statusText = "Off"
-
- self.tapDrainTask?.cancel()
- self.tapDrainTask = nil
- self.tapQueue?.clear()
- self.tapQueue = nil
-
- self.recognitionTask?.cancel()
- self.recognitionTask = nil
- self.recognitionRequest = nil
-
- if self.audioEngine.isRunning {
- self.audioEngine.stop()
- self.audioEngine.inputNode.removeTap(onBus: 0)
- }
-
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+ self.tearDownRecognitionPipeline()
}
/// Temporarily releases the microphone so other subsystems (e.g. camera video capture) can record audio.
@@ -241,22 +226,7 @@ final class VoiceWakeManager: NSObject {
self.isListening = false
self.statusText = "Paused"
-
- self.tapDrainTask?.cancel()
- self.tapDrainTask = nil
- self.tapQueue?.clear()
- self.tapQueue = nil
-
- self.recognitionTask?.cancel()
- self.recognitionTask = nil
- self.recognitionRequest = nil
-
- if self.audioEngine.isRunning {
- self.audioEngine.stop()
- self.audioEngine.inputNode.removeTap(onBus: 0)
- }
-
- try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+ self.tearDownRecognitionPipeline()
return true
}
@@ -310,6 +280,24 @@ final class VoiceWakeManager: NSObject {
}
}
+ private func tearDownRecognitionPipeline() {
+ self.tapDrainTask?.cancel()
+ self.tapDrainTask = nil
+ self.tapQueue?.clear()
+ self.tapQueue = nil
+
+ self.recognitionTask?.cancel()
+ self.recognitionTask = nil
+ self.recognitionRequest = nil
+
+ if self.audioEngine.isRunning {
+ self.audioEngine.stop()
+ self.audioEngine.inputNode.removeTap(onBus: 0)
+ }
+
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+ }
+
private nonisolated func makeRecognitionResultHandler() -> @Sendable (SFSpeechRecognitionResult?, Error?) -> Void {
{ [weak self] result, error in
let transcript = result?.bestTranscription.formattedString
@@ -404,16 +392,10 @@ final class VoiceWakeManager: NSObject {
}
private nonisolated static func microphonePermissionMessage(kind: String) -> String {
- switch AVAudioApplication.shared.recordPermission {
- case .denied:
- return "\(kind) permission denied"
- case .undetermined:
- return "\(kind) permission not granted"
- case .granted:
- return "\(kind) permission denied"
- @unknown default:
- return "\(kind) permission denied"
- }
+ let status = AVAudioApplication.shared.recordPermission
+ return self.deniedByDefaultPermissionMessage(
+ kind: kind,
+ isUndetermined: status == .undetermined)
}
private nonisolated static func requestSpeechPermission() async -> Bool {
@@ -459,22 +441,6 @@ final class VoiceWakeManager: NSObject {
}
}
- private static func permissionMessage(
- kind: String,
- status: AVAudioSession.RecordPermission) -> String
- {
- switch status {
- case .denied:
- return "\(kind) permission denied"
- case .undetermined:
- return "\(kind) permission not granted"
- case .granted:
- return "\(kind) permission denied"
- @unknown default:
- return "\(kind) permission denied"
- }
- }
-
private static func permissionMessage(
kind: String,
status: SFSpeechRecognizerAuthorizationStatus) -> String
@@ -492,6 +458,13 @@ final class VoiceWakeManager: NSObject {
return "\(kind) permission denied"
}
}
+
+ private nonisolated static func deniedByDefaultPermissionMessage(kind: String, isUndetermined: Bool) -> String {
+ if isUndetermined {
+ return "\(kind) permission not granted"
+ }
+ return "\(kind) permission denied"
+ }
}
#if DEBUG
diff --git a/apps/ios/Tests/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift
index 51ef9547a10a..7f24aa3e34ef 100644
--- a/apps/ios/Tests/DeepLinkParserTests.swift
+++ b/apps/ios/Tests/DeepLinkParserTests.swift
@@ -2,6 +2,36 @@ import OpenClawKit
import Foundation
import Testing
+private func setupCode(from payload: String) -> String {
+ Data(payload.utf8)
+ .base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: "")
+}
+
+private func agentAction(
+ message: String,
+ sessionKey: String? = nil,
+ thinking: String? = nil,
+ deliver: Bool = false,
+ to: String? = nil,
+ channel: String? = nil,
+ timeoutSeconds: Int? = nil,
+ key: String? = nil) -> DeepLinkRoute
+{
+ .agent(
+ .init(
+ message: message,
+ sessionKey: sessionKey,
+ thinking: thinking,
+ deliver: deliver,
+ to: to,
+ channel: channel,
+ timeoutSeconds: timeoutSeconds,
+ key: key))
+}
+
@Suite struct DeepLinkParserTests {
@Test func parseRejectsUnknownHost() {
let url = URL(string: "openclaw://nope?message=hi")!
@@ -10,15 +40,7 @@ import Testing
@Test func parseHostIsCaseInsensitive() {
let url = URL(string: "openclaw://AGENT?message=Hello")!
- #expect(DeepLinkParser.parse(url) == .agent(.init(
- message: "Hello",
- sessionKey: nil,
- thinking: nil,
- deliver: false,
- to: nil,
- channel: nil,
- timeoutSeconds: nil,
- key: nil)))
+ #expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
}
@Test func parseRejectsNonOpenClawScheme() {
@@ -34,47 +56,29 @@ import Testing
@Test func parseAgentLinkParsesCommonFields() {
let url =
URL(string: "openclaw://agent?message=Hello&deliver=1&sessionKey=node-test&thinking=low&timeoutSeconds=30")!
- #expect(
- DeepLinkParser.parse(url) == .agent(
- .init(
- message: "Hello",
- sessionKey: "node-test",
- thinking: "low",
- deliver: true,
- to: nil,
- channel: nil,
- timeoutSeconds: 30,
- key: nil)))
+ #expect(DeepLinkParser.parse(url) == agentAction(
+ message: "Hello",
+ sessionKey: "node-test",
+ thinking: "low",
+ deliver: true,
+ timeoutSeconds: 30))
}
@Test func parseAgentLinkParsesTargetRoutingFields() {
let url =
URL(
string: "openclaw://agent?message=Hello%20World&deliver=1&to=%2B15551234567&channel=whatsapp&key=secret")!
- #expect(
- DeepLinkParser.parse(url) == .agent(
- .init(
- message: "Hello World",
- sessionKey: nil,
- thinking: nil,
- deliver: true,
- to: "+15551234567",
- channel: "whatsapp",
- timeoutSeconds: nil,
- key: "secret")))
+ #expect(DeepLinkParser.parse(url) == agentAction(
+ message: "Hello World",
+ deliver: true,
+ to: "+15551234567",
+ channel: "whatsapp",
+ key: "secret"))
}
@Test func parseRejectsNegativeTimeoutSeconds() {
let url = URL(string: "openclaw://agent?message=Hello&timeoutSeconds=-1")!
- #expect(DeepLinkParser.parse(url) == .agent(.init(
- message: "Hello",
- sessionKey: nil,
- thinking: nil,
- deliver: false,
- to: nil,
- channel: nil,
- timeoutSeconds: nil,
- key: nil)))
+ #expect(DeepLinkParser.parse(url) == agentAction(message: "Hello"))
}
@Test func parseGatewayLinkParsesCommonFields() {
@@ -99,13 +103,7 @@ import Testing
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
- let encoded = Data(payload.utf8)
- .base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
-
- let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+ let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
@@ -121,13 +119,7 @@ import Testing
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
- let encoded = Data(payload.utf8)
- .base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
-
- let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+ let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "gateway.example.com",
@@ -139,37 +131,19 @@ import Testing
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
- let encoded = Data(payload.utf8)
- .base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
-
- let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+ let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
- let encoded = Data(payload.utf8)
- .base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
-
- let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+ let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == nil)
}
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
- let encoded = Data(payload.utf8)
- .base64EncodedString()
- .replacingOccurrences(of: "+", with: "-")
- .replacingOccurrences(of: "/", with: "_")
- .replacingOccurrences(of: "=", with: "")
-
- let link = GatewayConnectDeepLink.fromSetupCode(encoded)
+ let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
#expect(link == .init(
host: "127.0.0.1",
diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift
index 27e7aed7aea5..5559e42086e3 100644
--- a/apps/ios/Tests/GatewayConnectionControllerTests.swift
+++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift
@@ -4,31 +4,6 @@ import Testing
import UIKit
@testable import OpenClaw
-private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
- let defaults = UserDefaults.standard
- var snapshot: [String: Any?] = [:]
- for key in updates.keys {
- snapshot[key] = defaults.object(forKey: key)
- }
- for (key, value) in updates {
- if let value {
- defaults.set(value, forKey: key)
- } else {
- defaults.removeObject(forKey: key)
- }
- }
- defer {
- for (key, value) in snapshot {
- if let value {
- defaults.set(value, forKey: key)
- } else {
- defaults.removeObject(forKey: key)
- }
- }
- }
- return try body()
-}
-
@Suite(.serialized) struct GatewayConnectionControllerTests {
@Test @MainActor func resolvedDisplayNameSetsDefaultWhenMissing() {
let defaults = UserDefaults.standard
diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift
index 3c1b25bce077..06e11ec84378 100644
--- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift
+++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift
@@ -5,6 +5,32 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct GatewayConnectionSecurityTests {
+ private func makeController() -> GatewayConnectionController {
+ GatewayConnectionController(appModel: NodeAppModel(), startDiscovery: false)
+ }
+
+ private func makeDiscoveredGateway(
+ stableID: String,
+ lanHost: String?,
+ tailnetDns: String?,
+ gatewayPort: Int?,
+ fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway
+ {
+ let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
+ return GatewayDiscoveryModel.DiscoveredGateway(
+ name: "Test",
+ endpoint: endpoint,
+ stableID: stableID,
+ debugID: "debug",
+ lanHost: lanHost,
+ tailnetDns: tailnetDns,
+ gatewayPort: gatewayPort,
+ canvasPort: nil,
+ tlsEnabled: true,
+ tlsFingerprintSha256: fingerprint,
+ cliPath: nil)
+ }
+
private func clearTLSFingerprint(stableID: String) {
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
suite.removeObject(forKey: "gateway.tls.\(stableID)")
@@ -17,22 +43,13 @@ import Testing
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
- let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
- let gateway = GatewayDiscoveryModel.DiscoveredGateway(
- name: "Test",
- endpoint: endpoint,
+ let gateway = makeDiscoveredGateway(
stableID: stableID,
- debugID: "debug",
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
- canvasPort: nil,
- tlsEnabled: true,
- tlsFingerprintSha256: "22",
- cliPath: nil)
-
- let appModel = NodeAppModel()
- let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ fingerprint: "22")
+ let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == "11")
@@ -44,22 +61,13 @@ import Testing
defer { clearTLSFingerprint(stableID: stableID) }
clearTLSFingerprint(stableID: stableID)
- let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
- let gateway = GatewayDiscoveryModel.DiscoveredGateway(
- name: "Test",
- endpoint: endpoint,
+ let gateway = makeDiscoveredGateway(
stableID: stableID,
- debugID: "debug",
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
- canvasPort: nil,
- tlsEnabled: true,
- tlsFingerprintSha256: "22",
- cliPath: nil)
-
- let appModel = NodeAppModel()
- let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ fingerprint: "22")
+ let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == nil)
@@ -82,22 +90,13 @@ import Testing
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
- let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
- let gateway = GatewayDiscoveryModel.DiscoveredGateway(
- name: "Test",
- endpoint: endpoint,
+ let gateway = makeDiscoveredGateway(
stableID: stableID,
- debugID: "debug",
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
- canvasPort: nil,
- tlsEnabled: true,
- tlsFingerprintSha256: nil,
- cliPath: nil)
-
- let appModel = NodeAppModel()
- let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ fingerprint: nil)
+ let controller = makeController()
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
@@ -105,8 +104,7 @@ import Testing
}
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
- let appModel = NodeAppModel()
- let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == true)
@@ -121,8 +119,7 @@ import Testing
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
- let appModel = NodeAppModel()
- let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
+ let controller = makeController()
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)
diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift
index 0bac40152361..d7e12f02c01f 100644
--- a/apps/ios/Tests/GatewaySettingsStoreTests.swift
+++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift
@@ -14,6 +14,19 @@ private let instanceIdEntry = KeychainEntry(service: nodeService, account: "inst
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
+private let bootstrapDefaultsKeys = [
+ "node.instanceId",
+ "gateway.preferredStableID",
+ "gateway.lastDiscoveredStableID",
+]
+private let bootstrapKeychainEntries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
+private let lastGatewayDefaultsKeys = [
+ "gateway.last.kind",
+ "gateway.last.host",
+ "gateway.last.port",
+ "gateway.last.tls",
+ "gateway.last.stableID",
+]
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard
@@ -61,142 +74,112 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
applyKeychain(snapshot)
}
+private func withBootstrapSnapshots(_ body: () -> Void) {
+ let defaultsSnapshot = snapshotDefaults(bootstrapDefaultsKeys)
+ let keychainSnapshot = snapshotKeychain(bootstrapKeychainEntries)
+ defer {
+ restoreDefaults(defaultsSnapshot)
+ restoreKeychain(keychainSnapshot)
+ }
+ body()
+}
+
+private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
+ let snapshot = snapshotDefaults(lastGatewayDefaultsKeys)
+ defer { restoreDefaults(snapshot) }
+ body()
+}
+
@Suite(.serialized) struct GatewaySettingsStoreTests {
@Test func bootstrapCopiesDefaultsToKeychainWhenMissing() {
- let defaultsKeys = [
- "node.instanceId",
- "gateway.preferredStableID",
- "gateway.lastDiscoveredStableID",
- ]
- let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
- let defaultsSnapshot = snapshotDefaults(defaultsKeys)
- let keychainSnapshot = snapshotKeychain(entries)
- defer {
- restoreDefaults(defaultsSnapshot)
- restoreKeychain(keychainSnapshot)
+ withBootstrapSnapshots {
+ applyDefaults([
+ "node.instanceId": "node-test",
+ "gateway.preferredStableID": "preferred-test",
+ "gateway.lastDiscoveredStableID": "last-test",
+ ])
+ applyKeychain([
+ instanceIdEntry: nil,
+ preferredGatewayEntry: nil,
+ lastGatewayEntry: nil,
+ ])
+
+ GatewaySettingsStore.bootstrapPersistence()
+
+ #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
+ #expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
+ #expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
-
- applyDefaults([
- "node.instanceId": "node-test",
- "gateway.preferredStableID": "preferred-test",
- "gateway.lastDiscoveredStableID": "last-test",
- ])
- applyKeychain([
- instanceIdEntry: nil,
- preferredGatewayEntry: nil,
- lastGatewayEntry: nil,
- ])
-
- GatewaySettingsStore.bootstrapPersistence()
-
- #expect(KeychainStore.loadString(service: nodeService, account: "instanceId") == "node-test")
- #expect(KeychainStore.loadString(service: gatewayService, account: "preferredStableID") == "preferred-test")
- #expect(KeychainStore.loadString(service: gatewayService, account: "lastDiscoveredStableID") == "last-test")
}
@Test func bootstrapCopiesKeychainToDefaultsWhenMissing() {
- let defaultsKeys = [
- "node.instanceId",
- "gateway.preferredStableID",
- "gateway.lastDiscoveredStableID",
- ]
- let entries = [instanceIdEntry, preferredGatewayEntry, lastGatewayEntry]
- let defaultsSnapshot = snapshotDefaults(defaultsKeys)
- let keychainSnapshot = snapshotKeychain(entries)
- defer {
- restoreDefaults(defaultsSnapshot)
- restoreKeychain(keychainSnapshot)
+ withBootstrapSnapshots {
+ applyDefaults([
+ "node.instanceId": nil,
+ "gateway.preferredStableID": nil,
+ "gateway.lastDiscoveredStableID": nil,
+ ])
+ applyKeychain([
+ instanceIdEntry: "node-from-keychain",
+ preferredGatewayEntry: "preferred-from-keychain",
+ lastGatewayEntry: "last-from-keychain",
+ ])
+
+ GatewaySettingsStore.bootstrapPersistence()
+
+ let defaults = UserDefaults.standard
+ #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
+ #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
+ #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
-
- applyDefaults([
- "node.instanceId": nil,
- "gateway.preferredStableID": nil,
- "gateway.lastDiscoveredStableID": nil,
- ])
- applyKeychain([
- instanceIdEntry: "node-from-keychain",
- preferredGatewayEntry: "preferred-from-keychain",
- lastGatewayEntry: "last-from-keychain",
- ])
-
- GatewaySettingsStore.bootstrapPersistence()
-
- let defaults = UserDefaults.standard
- #expect(defaults.string(forKey: "node.instanceId") == "node-from-keychain")
- #expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
- #expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
}
@Test func lastGateway_manualRoundTrip() {
- let keys = [
- "gateway.last.kind",
- "gateway.last.host",
- "gateway.last.port",
- "gateway.last.tls",
- "gateway.last.stableID",
- ]
- let snapshot = snapshotDefaults(keys)
- defer { restoreDefaults(snapshot) }
-
- GatewaySettingsStore.saveLastGatewayConnectionManual(
- host: "example.com",
- port: 443,
- useTLS: true,
- stableID: "manual|example.com|443")
-
- let loaded = GatewaySettingsStore.loadLastGatewayConnection()
- #expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
+ withLastGatewayDefaultsSnapshot {
+ GatewaySettingsStore.saveLastGatewayConnectionManual(
+ host: "example.com",
+ port: 443,
+ useTLS: true,
+ stableID: "manual|example.com|443")
+
+ let loaded = GatewaySettingsStore.loadLastGatewayConnection()
+ #expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
+ }
}
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
- let keys = [
- "gateway.last.kind",
- "gateway.last.host",
- "gateway.last.port",
- "gateway.last.tls",
- "gateway.last.stableID",
- ]
- let snapshot = snapshotDefaults(keys)
- defer { restoreDefaults(snapshot) }
-
- // 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",
- ])
-
- 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))
+ 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",
+ ])
+
+ 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() {
- let keys = [
- "gateway.last.kind",
- "gateway.last.host",
- "gateway.last.port",
- "gateway.last.tls",
- "gateway.last.stableID",
- ]
- let snapshot = snapshotDefaults(keys)
- defer { restoreDefaults(snapshot) }
-
- applyDefaults([
- "gateway.last.kind": nil,
- "gateway.last.host": "example.org",
- "gateway.last.port": 18789,
- "gateway.last.tls": false,
- "gateway.last.stableID": "manual|example.org|18789",
- ])
-
- let loaded = GatewaySettingsStore.loadLastGatewayConnection()
- #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
+ withLastGatewayDefaultsSnapshot {
+ applyDefaults([
+ "gateway.last.kind": nil,
+ "gateway.last.host": "example.org",
+ "gateway.last.port": 18789,
+ "gateway.last.tls": false,
+ "gateway.last.stableID": "manual|example.org|18789",
+ ])
+
+ let loaded = GatewaySettingsStore.loadLastGatewayConnection()
+ #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
+ }
}
@Test func talkProviderApiKey_genericRoundTrip() {
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index 9e3848db518f..51f99d987c4e 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 2026.3.1
+ 2026.3.2
CFBundleVersion
20260301
diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift
index dbeee118a4a4..c12c97278748 100644
--- a/apps/ios/Tests/NodeAppModelInvokeTests.swift
+++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift
@@ -4,31 +4,6 @@ import Testing
import UIKit
@testable import OpenClaw
-private func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
- let defaults = UserDefaults.standard
- var snapshot: [String: Any?] = [:]
- for key in updates.keys {
- snapshot[key] = defaults.object(forKey: key)
- }
- for (key, value) in updates {
- if let value {
- defaults.set(value, forKey: key)
- } else {
- defaults.removeObject(forKey: key)
- }
- }
- defer {
- for (key, value) in snapshot {
- if let value {
- defaults.set(value, forKey: key)
- } else {
- defaults.removeObject(forKey: key)
- }
- }
- }
- return try body()
-}
-
private func makeAgentDeepLinkURL(
message: String,
deliver: Bool = false,
diff --git a/apps/ios/Tests/TestDefaultsSupport.swift b/apps/ios/Tests/TestDefaultsSupport.swift
new file mode 100644
index 000000000000..75fd2344aa3f
--- /dev/null
+++ b/apps/ios/Tests/TestDefaultsSupport.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+func withUserDefaults(_ updates: [String: Any?], _ body: () throws -> T) rethrows -> T {
+ let defaults = UserDefaults.standard
+ var snapshot: [String: Any?] = [:]
+ for key in updates.keys {
+ snapshot[key] = defaults.object(forKey: key)
+ }
+ for (key, value) in updates {
+ if let value {
+ defaults.set(value, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ defer {
+ for (key, value) in snapshot {
+ if let value {
+ defaults.set(value, forKey: key)
+ } else {
+ defaults.removeObject(forKey: key)
+ }
+ }
+ }
+ return try body()
+}
diff --git a/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift b/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift
index f6b0378cd6bd..2e8b1ee7c407 100644
--- a/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift
+++ b/apps/ios/Tests/VoiceWakeManagerExtractCommandTests.swift
@@ -3,6 +3,19 @@ import SwabbleKit
import Testing
@testable import OpenClaw
+private let openclawTranscript = "hey openclaw do thing"
+
+private func openclawSegments(postTriggerStart: TimeInterval) -> [WakeWordSegment] {
+ makeSegments(
+ transcript: openclawTranscript,
+ words: [
+ ("hey", 0.0, 0.1),
+ ("openclaw", 0.2, 0.1),
+ ("do", postTriggerStart, 0.1),
+ ("thing", postTriggerStart + 0.2, 0.1),
+ ])
+}
+
@Suite struct VoiceWakeManagerExtractCommandTests {
@Test func extractCommandReturnsNilWhenNoTriggerFound() {
let transcript = "hello world"
@@ -13,17 +26,9 @@ import Testing
}
@Test func extractCommandTrimsTokensAndResult() {
- let transcript = "hey openclaw do thing"
- let segments = makeSegments(
- transcript: transcript,
- words: [
- ("hey", 0.0, 0.1),
- ("openclaw", 0.2, 0.1),
- ("do", 0.9, 0.1),
- ("thing", 1.1, 0.1),
- ])
+ let segments = openclawSegments(postTriggerStart: 0.9)
let cmd = VoiceWakeManager.extractCommand(
- from: transcript,
+ from: openclawTranscript,
segments: segments,
triggers: [" openclaw "],
minPostTriggerGap: 0.3)
@@ -31,17 +36,9 @@ import Testing
}
@Test func extractCommandReturnsNilWhenGapTooShort() {
- let transcript = "hey openclaw do thing"
- let segments = makeSegments(
- transcript: transcript,
- words: [
- ("hey", 0.0, 0.1),
- ("openclaw", 0.2, 0.1),
- ("do", 0.35, 0.1),
- ("thing", 0.5, 0.1),
- ])
+ let segments = openclawSegments(postTriggerStart: 0.35)
let cmd = VoiceWakeManager.extractCommand(
- from: transcript,
+ from: openclawTranscript,
segments: segments,
triggers: ["openclaw"],
minPostTriggerGap: 0.3)
@@ -57,17 +54,9 @@ import Testing
}
@Test func extractCommandIgnoresEmptyTriggers() {
- let transcript = "hey openclaw do thing"
- let segments = makeSegments(
- transcript: transcript,
- words: [
- ("hey", 0.0, 0.1),
- ("openclaw", 0.2, 0.1),
- ("do", 0.9, 0.1),
- ("thing", 1.1, 0.1),
- ])
+ let segments = openclawSegments(postTriggerStart: 0.9)
let cmd = VoiceWakeManager.extractCommand(
- from: transcript,
+ from: openclawTranscript,
segments: segments,
triggers: ["", " ", "openclaw"],
minPostTriggerGap: 0.3)
diff --git a/apps/ios/WatchApp/Info.plist b/apps/ios/WatchApp/Info.plist
index c64ef51e4d61..c0041b2a11d1 100644
--- a/apps/ios/WatchApp/Info.plist
+++ b/apps/ios/WatchApp/Info.plist
@@ -17,7 +17,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.1
+ 2026.3.2
CFBundleVersion
20260301
WKCompanionAppBundleIdentifier
diff --git a/apps/ios/WatchExtension/Info.plist b/apps/ios/WatchExtension/Info.plist
index b8d9f34ac8e3..45029fa75694 100644
--- a/apps/ios/WatchExtension/Info.plist
+++ b/apps/ios/WatchExtension/Info.plist
@@ -15,7 +15,7 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 2026.3.1
+ 2026.3.2
CFBundleVersion
20260301
NSExtension
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index fdc19c827a85..1f3cad955bf3 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -95,7 +95,7 @@ targets:
- CFBundleURLName: ai.openclaw.ios
CFBundleURLSchemes:
- openclaw
- CFBundleShortVersionString: "2026.3.1"
+ CFBundleShortVersionString: "2026.3.2"
CFBundleVersion: "20260301"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -152,7 +152,7 @@ targets:
path: ShareExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw Share
- CFBundleShortVersionString: "2026.3.1"
+ CFBundleShortVersionString: "2026.3.2"
CFBundleVersion: "20260301"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -184,7 +184,7 @@ targets:
path: WatchApp/Info.plist
properties:
CFBundleDisplayName: OpenClaw
- CFBundleShortVersionString: "2026.3.1"
+ CFBundleShortVersionString: "2026.3.2"
CFBundleVersion: "20260301"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKWatchKitApp: true
@@ -209,7 +209,7 @@ targets:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
- CFBundleShortVersionString: "2026.3.1"
+ CFBundleShortVersionString: "2026.3.2"
CFBundleVersion: "20260301"
NSExtension:
NSExtensionAttributes:
@@ -244,5 +244,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
- CFBundleShortVersionString: "2026.3.1"
+ CFBundleShortVersionString: "2026.3.2"
CFBundleVersion: "20260301"
diff --git a/apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift b/apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift
new file mode 100644
index 000000000000..a7a5ade51d6d
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/AgentWorkspaceConfig.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+enum AgentWorkspaceConfig {
+ static func workspace(from root: [String: Any]) -> String? {
+ let agents = root["agents"] as? [String: Any]
+ let defaults = agents?["defaults"] as? [String: Any]
+ return defaults?["workspace"] as? String
+ }
+
+ static func setWorkspace(in root: inout [String: Any], workspace: String?) {
+ var agents = root["agents"] as? [String: Any] ?? [:]
+ var defaults = agents["defaults"] as? [String: Any] ?? [:]
+ let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ if trimmed.isEmpty {
+ defaults.removeValue(forKey: "workspace")
+ } else {
+ defaults["workspace"] = trimmed
+ }
+ if defaults.isEmpty {
+ agents.removeValue(forKey: "defaults")
+ } else {
+ agents["defaults"] = defaults
+ }
+ if agents.isEmpty {
+ root.removeValue(forKey: "agents")
+ } else {
+ root["agents"] = agents
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift
index 6c01628144b0..43d92a8dd1ed 100644
--- a/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift
+++ b/apps/macos/Sources/OpenClaw/AudioInputDeviceObserver.swift
@@ -9,21 +9,7 @@ final class AudioInputDeviceObserver {
private var defaultInputListener: AudioObjectPropertyListenerBlock?
static func defaultInputDeviceUID() -> String? {
- let systemObject = AudioObjectID(kAudioObjectSystemObject)
- var address = AudioObjectPropertyAddress(
- mSelector: kAudioHardwarePropertyDefaultInputDevice,
- mScope: kAudioObjectPropertyScopeGlobal,
- mElement: kAudioObjectPropertyElementMain)
- var deviceID = AudioObjectID(0)
- var size = UInt32(MemoryLayout.size)
- let status = AudioObjectGetPropertyData(
- systemObject,
- &address,
- 0,
- nil,
- &size,
- &deviceID)
- guard status == noErr, deviceID != 0 else { return nil }
+ guard let deviceID = self.defaultInputDeviceID() else { return nil }
return self.deviceUID(for: deviceID)
}
@@ -63,6 +49,15 @@ final class AudioInputDeviceObserver {
}
static func defaultInputDeviceSummary() -> String {
+ guard let deviceID = self.defaultInputDeviceID() else {
+ return "defaultInput=unknown"
+ }
+ let uid = self.deviceUID(for: deviceID) ?? "unknown"
+ let name = self.deviceName(for: deviceID) ?? "unknown"
+ return "defaultInput=\(name) (\(uid))"
+ }
+
+ private static func defaultInputDeviceID() -> AudioObjectID? {
let systemObject = AudioObjectID(kAudioObjectSystemObject)
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultInputDevice,
@@ -77,12 +72,8 @@ final class AudioInputDeviceObserver {
nil,
&size,
&deviceID)
- guard status == noErr, deviceID != 0 else {
- return "defaultInput=unknown"
- }
- let uid = self.deviceUID(for: deviceID) ?? "unknown"
- let name = self.deviceName(for: deviceID) ?? "unknown"
- return "defaultInput=\(name) (\(uid))"
+ guard status == noErr, deviceID != 0 else { return nil }
+ return deviceID
}
func start(onChange: @escaping @Sendable () -> Void) {
diff --git a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift
index 4e3749d6a68d..29f532dce2ef 100644
--- a/apps/macos/Sources/OpenClaw/CameraCaptureService.swift
+++ b/apps/macos/Sources/OpenClaw/CameraCaptureService.swift
@@ -64,45 +64,33 @@ actor CameraCaptureService {
try await self.ensureAccess(for: .video)
- let session = AVCaptureSession()
- session.sessionPreset = .photo
-
- guard let device = Self.pickCamera(facing: facing, deviceId: deviceId) else {
- throw CameraError.cameraUnavailable
- }
-
- let input = try AVCaptureDeviceInput(device: device)
- guard session.canAddInput(input) else {
- throw CameraError.captureFailed("Failed to add camera input")
- }
- session.addInput(input)
-
- let output = AVCapturePhotoOutput()
- guard session.canAddOutput(output) else {
- throw CameraError.captureFailed("Failed to add photo output")
- }
- session.addOutput(output)
- output.maxPhotoQualityPrioritization = .quality
+ let prepared = try CameraCapturePipelineSupport.preparePhotoSession(
+ preferFrontCamera: facing == .front,
+ deviceId: deviceId,
+ pickCamera: { preferFrontCamera, deviceId in
+ Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
+ },
+ cameraUnavailableError: CameraError.cameraUnavailable,
+ mapSetupError: { setupError in
+ CameraError.captureFailed(setupError.localizedDescription)
+ })
+ let session = prepared.session
+ let device = prepared.device
+ let output = prepared.output
session.startRunning()
defer { session.stopRunning() }
- await Self.warmUpCaptureSession()
+ await CameraCapturePipelineSupport.warmUpCaptureSession()
await self.waitForExposureAndWhiteBalance(device: device)
await self.sleepDelayMs(delayMs)
- let settings: AVCapturePhotoSettings = {
- if output.availablePhotoCodecTypes.contains(.jpeg) {
- return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
- }
- return AVCapturePhotoSettings()
- }()
- settings.photoQualityPrioritization = .quality
-
var delegate: PhotoCaptureDelegate?
- let rawData: Data = try await withCheckedThrowingContinuation { cont in
- let d = PhotoCaptureDelegate(cont)
- delegate = d
- output.capturePhoto(with: settings, delegate: d)
+ let rawData: Data = try await withCheckedThrowingContinuation { continuation in
+ let captureDelegate = PhotoCaptureDelegate(continuation)
+ delegate = captureDelegate
+ output.capturePhoto(
+ with: CameraCapturePipelineSupport.makePhotoSettings(output: output),
+ delegate: captureDelegate)
}
withExtendedLifetime(delegate) {}
@@ -135,39 +123,19 @@ actor CameraCaptureService {
try await self.ensureAccess(for: .audio)
}
- let session = AVCaptureSession()
- session.sessionPreset = .high
-
- guard let camera = Self.pickCamera(facing: facing, deviceId: deviceId) else {
- throw CameraError.cameraUnavailable
- }
- let cameraInput = try AVCaptureDeviceInput(device: camera)
- guard session.canAddInput(cameraInput) else {
- throw CameraError.captureFailed("Failed to add camera input")
- }
- session.addInput(cameraInput)
-
- if includeAudio {
- guard let mic = AVCaptureDevice.default(for: .audio) else {
- throw CameraError.microphoneUnavailable
- }
- let micInput = try AVCaptureDeviceInput(device: mic)
- guard session.canAddInput(micInput) else {
- throw CameraError.captureFailed("Failed to add microphone input")
- }
- session.addInput(micInput)
- }
-
- let output = AVCaptureMovieFileOutput()
- guard session.canAddOutput(output) else {
- throw CameraError.captureFailed("Failed to add movie output")
- }
- session.addOutput(output)
- output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
-
- session.startRunning()
+ let prepared = try await CameraCapturePipelineSupport.prepareWarmMovieSession(
+ preferFrontCamera: facing == .front,
+ deviceId: deviceId,
+ includeAudio: includeAudio,
+ durationMs: durationMs,
+ pickCamera: { preferFrontCamera, deviceId in
+ Self.pickCamera(facing: preferFrontCamera ? .front : .back, deviceId: deviceId)
+ },
+ cameraUnavailableError: CameraError.cameraUnavailable,
+ mapSetupError: Self.mapMovieSetupError)
+ let session = prepared.session
+ let output = prepared.output
defer { session.stopRunning() }
- await Self.warmUpCaptureSession()
let tmpMovURL = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mov")
@@ -180,7 +148,6 @@ actor CameraCaptureService {
return FileManager().temporaryDirectory
.appendingPathComponent("openclaw-camera-\(UUID().uuidString).mp4")
}()
-
// Ensure we don't fail exporting due to an existing file.
try? FileManager().removeItem(at: outputURL)
@@ -192,28 +159,12 @@ actor CameraCaptureService {
output.startRecording(to: tmpMovURL, recordingDelegate: d)
}
withExtendedLifetime(delegate) {}
-
try await Self.exportToMP4(inputURL: recordedURL, outputURL: outputURL)
return (path: outputURL.path, durationMs: durationMs, hasAudio: includeAudio)
}
private func ensureAccess(for mediaType: AVMediaType) async throws {
- let status = AVCaptureDevice.authorizationStatus(for: mediaType)
- switch status {
- case .authorized:
- return
- case .notDetermined:
- let ok = await withCheckedContinuation(isolation: nil) { cont in
- AVCaptureDevice.requestAccess(for: mediaType) { granted in
- cont.resume(returning: granted)
- }
- }
- if !ok {
- throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
- }
- case .denied, .restricted:
- throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
- @unknown default:
+ if await !(CameraAuthorization.isAuthorized(for: mediaType)) {
throw CameraError.permissionDenied(kind: mediaType == .video ? "Camera" : "Microphone")
}
}
@@ -278,6 +229,13 @@ actor CameraCaptureService {
return min(60000, max(250, v))
}
+ private nonisolated static func mapMovieSetupError(_ setupError: CameraSessionConfigurationError) -> CameraError {
+ CameraCapturePipelineSupport.mapMovieSetupError(
+ setupError,
+ microphoneUnavailableError: .microphoneUnavailable,
+ captureFailed: { .captureFailed($0) })
+ }
+
private nonisolated static func exportToMP4(inputURL: URL, outputURL: URL) async throws {
let asset = AVURLAsset(url: inputURL)
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {
@@ -315,11 +273,6 @@ actor CameraCaptureService {
}
}
- private nonisolated static func warmUpCaptureSession() async {
- // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
- try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
- }
-
private func waitForExposureAndWhiteBalance(device: AVCaptureDevice) async {
let stepNs: UInt64 = 50_000_000
let maxSteps = 30 // ~1.5s
@@ -338,11 +291,7 @@ actor CameraCaptureService {
}
private nonisolated static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
- switch position {
- case .front: "front"
- case .back: "back"
- default: "unspecified"
- }
+ CameraCapturePipelineSupport.positionLabel(position)
}
}
diff --git a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
index 40f443c5c8b8..4f47ea835dfb 100644
--- a/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasA2UIActionMessageHandler.swift
@@ -109,40 +109,7 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
}
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
- return false
- }
- guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
- return false
- }
- if host == "localhost" { return true }
- if host.hasSuffix(".local") { return true }
- if host.hasSuffix(".ts.net") { return true }
- if host.hasSuffix(".tailscale.net") { return true }
- if !host.contains("."), !host.contains(":") { return true }
- if let ipv4 = Self.parseIPv4(host) {
- return Self.isLocalNetworkIPv4(ipv4)
- }
- return false
- }
-
- static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
- 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 isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
- 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
+ LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
}
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
diff --git a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift
index 3ed0d67ffbcb..16cf8a39c397 100644
--- a/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasFileWatcher.swift
@@ -1,24 +1,12 @@
import Foundation
-final class CanvasFileWatcher: @unchecked Sendable {
- private let watcher: CoalescingFSEventsWatcher
+final class CanvasFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
+ let watcher: SimpleFileWatcher
init(url: URL, onChange: @escaping () -> Void) {
- self.watcher = CoalescingFSEventsWatcher(
+ self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
paths: [url.path],
queueLabel: "ai.openclaw.canvaswatcher",
- onChange: onChange)
- }
-
- deinit {
- self.stop()
- }
-
- func start() {
- self.watcher.start()
- }
-
- func stop() {
- self.watcher.stop()
+ onChange: onChange))
}
}
diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift
index 6c53fbc9971c..c2442d7e17bf 100644
--- a/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasWindowController+Testing.swift
@@ -25,11 +25,22 @@ extension CanvasWindowController {
}
static func _testParseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
- CanvasA2UIActionMessageHandler.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 {
- CanvasA2UIActionMessageHandler.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 {
diff --git a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift
index d30f54186aee..8017304087e1 100644
--- a/apps/macos/Sources/OpenClaw/CanvasWindowController.swift
+++ b/apps/macos/Sources/OpenClaw/CanvasWindowController.swift
@@ -274,25 +274,11 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
func applyDebugStatusIfNeeded() {
- let enabled = self.debugStatusEnabled
- let title = Self.jsOptionalStringLiteral(self.debugStatusTitle)
- let subtitle = Self.jsOptionalStringLiteral(self.debugStatusSubtitle)
- let js = """
- (() => {
- try {
- const api = globalThis.__openclaw;
- if (!api) return;
- if (typeof api.setDebugStatusEnabled === 'function') {
- api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
- }
- if (!\(enabled ? "true" : "false")) return;
- if (typeof api.setStatus === 'function') {
- api.setStatus(\(title), \(subtitle));
- }
- } catch (_) {}
- })();
- """
- self.webView.evaluateJavaScript(js) { _, _ in }
+ WebViewJavaScriptSupport.applyDebugStatus(
+ webView: self.webView,
+ enabled: self.debugStatusEnabled,
+ title: self.debugStatusTitle,
+ subtitle: self.debugStatusSubtitle)
}
private func loadFile(_ url: URL) {
@@ -302,19 +288,7 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
}
func eval(javaScript: String) async throws -> String {
- try await withCheckedThrowingContinuation { cont in
- self.webView.evaluateJavaScript(javaScript) { result, error in
- if let error {
- cont.resume(throwing: error)
- return
- }
- if let result {
- cont.resume(returning: String(describing: result))
- } else {
- cont.resume(returning: "")
- }
- }
- }
+ try await WebViewJavaScriptSupport.evaluateToString(webView: self.webView, javaScript: javaScript)
}
func snapshot(to outPath: String?) async throws -> String {
diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift
index 5be5818425b0..10ca93f73e08 100644
--- a/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift
+++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+ChannelState.swift
@@ -9,6 +9,90 @@ extension ChannelsSettings {
self.store.snapshot?.decodeChannel(id, as: type)
}
+ private func configuredChannelTint(configured: Bool, running: Bool, hasError: Bool, probeOk: Bool?) -> Color {
+ if !configured { return .secondary }
+ if hasError { return .orange }
+ if probeOk == false { return .orange }
+ if running { return .green }
+ return .orange
+ }
+
+ private func configuredChannelSummary(configured: Bool, running: Bool) -> String {
+ if !configured { return "Not configured" }
+ if running { return "Running" }
+ return "Configured"
+ }
+
+ private func appendProbeDetails(
+ lines: inout [String],
+ probeOk: Bool?,
+ probeStatus: Int?,
+ probeElapsedMs: Double?,
+ probeVersion: String? = nil,
+ probeError: String? = nil,
+ lastProbeAtMs: Double?,
+ lastError: String?)
+ {
+ if let probeOk {
+ if probeOk {
+ if let version = probeVersion, !version.isEmpty {
+ lines.append("Version \(version)")
+ }
+ if let elapsed = probeElapsedMs {
+ lines.append("Probe \(Int(elapsed))ms")
+ }
+ } else if let probeError, !probeError.isEmpty {
+ lines.append("Probe error: \(probeError)")
+ } else {
+ let code = probeStatus.map { String($0) } ?? "unknown"
+ lines.append("Probe failed (\(code))")
+ }
+ }
+ if let last = self.date(fromMs: lastProbeAtMs) {
+ lines.append("Last probe \(relativeAge(from: last))")
+ }
+ if let lastError, !lastError.isEmpty {
+ lines.append("Error: \(lastError)")
+ }
+ }
+
+ private func finishDetails(
+ lines: inout [String],
+ probeOk: Bool?,
+ probeStatus: Int?,
+ probeElapsedMs: Double?,
+ probeVersion: String? = nil,
+ probeError: String? = nil,
+ lastProbeAtMs: Double?,
+ lastError: String?) -> String?
+ {
+ self.appendProbeDetails(
+ lines: &lines,
+ probeOk: probeOk,
+ probeStatus: probeStatus,
+ probeElapsedMs: probeElapsedMs,
+ probeVersion: probeVersion,
+ probeError: probeError,
+ lastProbeAtMs: lastProbeAtMs,
+ lastError: lastError)
+ return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ }
+
+ private func finishProbeDetails(
+ lines: inout [String],
+ probe: (ok: Bool?, status: Int?, elapsedMs: Double?),
+ lastProbeAtMs: Double?,
+ lastError: String?) -> String?
+ {
+ self.finishDetails(
+ lines: &lines,
+ probeOk: probe.ok,
+ probeStatus: probe.status,
+ probeElapsedMs: probe.elapsedMs,
+ lastProbeAtMs: lastProbeAtMs,
+ lastError: lastError)
+ }
+
var whatsAppTint: Color {
guard let status = self.channelStatus("whatsapp", as: ChannelsStatusSnapshot.WhatsAppStatus.self)
else { return .secondary }
@@ -23,51 +107,51 @@ extension ChannelsSettings {
var telegramTint: Color {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return .secondary }
- if !status.configured { return .secondary }
- if status.lastError != nil { return .orange }
- if status.probe?.ok == false { return .orange }
- if status.running { return .green }
- return .orange
+ return self.configuredChannelTint(
+ configured: status.configured,
+ running: status.running,
+ hasError: status.lastError != nil,
+ probeOk: status.probe?.ok)
}
var discordTint: Color {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return .secondary }
- if !status.configured { return .secondary }
- if status.lastError != nil { return .orange }
- if status.probe?.ok == false { return .orange }
- if status.running { return .green }
- return .orange
+ return self.configuredChannelTint(
+ configured: status.configured,
+ running: status.running,
+ hasError: status.lastError != nil,
+ probeOk: status.probe?.ok)
}
var googlechatTint: Color {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return .secondary }
- if !status.configured { return .secondary }
- if status.lastError != nil { return .orange }
- if status.probe?.ok == false { return .orange }
- if status.running { return .green }
- return .orange
+ return self.configuredChannelTint(
+ configured: status.configured,
+ running: status.running,
+ hasError: status.lastError != nil,
+ probeOk: status.probe?.ok)
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
- if !status.configured { return .secondary }
- if status.lastError != nil { return .orange }
- if status.probe?.ok == false { return .orange }
- if status.running { return .green }
- return .orange
+ return self.configuredChannelTint(
+ configured: status.configured,
+ running: status.running,
+ hasError: status.lastError != nil,
+ probeOk: status.probe?.ok)
}
var imessageTint: Color {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return .secondary }
- if !status.configured { return .secondary }
- if status.lastError != nil { return .orange }
- if status.probe?.ok == false { return .orange }
- if status.running { return .green }
- return .orange
+ return self.configuredChannelTint(
+ configured: status.configured,
+ running: status.running,
+ hasError: status.lastError != nil,
+ probeOk: status.probe?.ok)
}
var whatsAppSummary: String {
@@ -82,41 +166,31 @@ extension ChannelsSettings {
var telegramSummary: String {
guard let status = self.channelStatus("telegram", as: ChannelsStatusSnapshot.TelegramStatus.self)
else { return "Checking…" }
- if !status.configured { return "Not configured" }
- if status.running { return "Running" }
- return "Configured"
+ return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var discordSummary: String {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return "Checking…" }
- if !status.configured { return "Not configured" }
- if status.running { return "Running" }
- return "Configured"
+ return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var googlechatSummary: String {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return "Checking…" }
- if !status.configured { return "Not configured" }
- if status.running { return "Running" }
- return "Configured"
+ return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
- if !status.configured { return "Not configured" }
- if status.running { return "Running" }
- return "Configured"
+ return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var imessageSummary: String {
guard let status = self.channelStatus("imessage", as: ChannelsStatusSnapshot.IMessageStatus.self)
else { return "Checking…" }
- if !status.configured { return "Not configured" }
- if status.running { return "Running" }
- return "Configured"
+ return self.configuredChannelSummary(configured: status.configured, running: status.running)
}
var whatsAppDetails: String? {
@@ -168,18 +242,15 @@ extension ChannelsSettings {
if let url = probe.webhook?.url, !url.isEmpty {
lines.append("Webhook: \(url)")
}
- } else {
- let code = probe.status.map { String($0) } ?? "unknown"
- lines.append("Probe failed (\(code))")
}
}
- if let last = self.date(fromMs: status.lastProbeAt) {
- lines.append("Last probe \(relativeAge(from: last))")
- }
- if let err = status.lastError, !err.isEmpty {
- lines.append("Error: \(err)")
- }
- return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ return self.finishDetails(
+ lines: &lines,
+ probeOk: status.probe?.ok,
+ probeStatus: status.probe?.status,
+ probeElapsedMs: nil,
+ lastProbeAtMs: status.lastProbeAt,
+ lastError: status.lastError)
}
var discordDetails: String? {
@@ -189,26 +260,17 @@ extension ChannelsSettings {
if let source = status.tokenSource {
lines.append("Token source: \(source)")
}
- if let probe = status.probe {
- if probe.ok {
- if let name = probe.bot?.username {
- lines.append("Bot: @\(name)")
- }
- if let elapsed = probe.elapsedMs {
- lines.append("Probe \(Int(elapsed))ms")
- }
- } else {
- let code = probe.status.map { String($0) } ?? "unknown"
- lines.append("Probe failed (\(code))")
- }
- }
- if let last = self.date(fromMs: status.lastProbeAt) {
- lines.append("Last probe \(relativeAge(from: last))")
- }
- if let err = status.lastError, !err.isEmpty {
- lines.append("Error: \(err)")
+ if let name = status.probe?.bot?.username, !name.isEmpty {
+ lines.append("Bot: @\(name)")
}
- return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ return self.finishProbeDetails(
+ lines: &lines,
+ probe: (
+ ok: status.probe?.ok,
+ status: status.probe?.status,
+ elapsedMs: status.probe?.elapsedMs),
+ lastProbeAtMs: status.lastProbeAt,
+ lastError: status.lastError)
}
var googlechatDetails: String? {
@@ -223,23 +285,14 @@ extension ChannelsSettings {
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
lines.append("Audience: \(label)")
}
- if let probe = status.probe {
- if probe.ok {
- if let elapsed = probe.elapsedMs {
- lines.append("Probe \(Int(elapsed))ms")
- }
- } else {
- let code = probe.status.map { String($0) } ?? "unknown"
- lines.append("Probe failed (\(code))")
- }
- }
- if let last = self.date(fromMs: status.lastProbeAt) {
- lines.append("Last probe \(relativeAge(from: last))")
- }
- if let err = status.lastError, !err.isEmpty {
- lines.append("Error: \(err)")
- }
- return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ return self.finishProbeDetails(
+ lines: &lines,
+ probe: (
+ ok: status.probe?.ok,
+ status: status.probe?.status,
+ elapsedMs: status.probe?.elapsedMs),
+ lastProbeAtMs: status.lastProbeAt,
+ lastError: status.lastError)
}
var signalDetails: String? {
@@ -247,26 +300,14 @@ extension ChannelsSettings {
else { return nil }
var lines: [String] = []
lines.append("Base URL: \(status.baseUrl)")
- if let probe = status.probe {
- if probe.ok {
- if let version = probe.version, !version.isEmpty {
- lines.append("Version \(version)")
- }
- if let elapsed = probe.elapsedMs {
- lines.append("Probe \(Int(elapsed))ms")
- }
- } else {
- let code = probe.status.map { String($0) } ?? "unknown"
- lines.append("Probe failed (\(code))")
- }
- }
- if let last = self.date(fromMs: status.lastProbeAt) {
- lines.append("Last probe \(relativeAge(from: last))")
- }
- if let err = status.lastError, !err.isEmpty {
- lines.append("Error: \(err)")
- }
- return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ return self.finishDetails(
+ lines: &lines,
+ probeOk: status.probe?.ok,
+ probeStatus: status.probe?.status,
+ probeElapsedMs: status.probe?.elapsedMs,
+ probeVersion: status.probe?.version,
+ lastProbeAtMs: status.lastProbeAt,
+ lastError: status.lastError)
}
var imessageDetails: String? {
@@ -279,17 +320,14 @@ extension ChannelsSettings {
if let dbPath = status.dbPath, !dbPath.isEmpty {
lines.append("DB: \(dbPath)")
}
- if let probe = status.probe, !probe.ok {
- let err = probe.error ?? "probe failed"
- lines.append("Probe error: \(err)")
- }
- if let last = self.date(fromMs: status.lastProbeAt) {
- lines.append("Last probe \(relativeAge(from: last))")
- }
- if let err = status.lastError, !err.isEmpty {
- lines.append("Error: \(err)")
- }
- return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ return self.finishDetails(
+ lines: &lines,
+ probeOk: status.probe?.ok,
+ probeStatus: nil,
+ probeElapsedMs: nil,
+ probeError: status.probe?.error,
+ lastProbeAtMs: status.lastProbeAt,
+ lastError: status.lastError)
}
var orderedChannels: [ChannelItem] {
diff --git a/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift b/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift
index d1ed16bf6e83..9b3976f3bae9 100644
--- a/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift
+++ b/apps/macos/Sources/OpenClaw/ChannelsSettings+View.swift
@@ -18,7 +18,7 @@ extension ChannelsSettings {
}
private var sidebar: some View {
- ScrollView {
+ SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
if !self.enabledChannels.isEmpty {
self.sidebarSectionHeader("Configured")
@@ -34,14 +34,7 @@ extension ChannelsSettings {
}
}
}
- .padding(.vertical, 10)
- .padding(.horizontal, 10)
}
- .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(nsColor: .windowBackgroundColor)))
- .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
diff --git a/apps/macos/Sources/OpenClaw/ColorHexSupport.swift b/apps/macos/Sources/OpenClaw/ColorHexSupport.swift
new file mode 100644
index 000000000000..506f2f1fb4ad
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/ColorHexSupport.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+enum ColorHexSupport {
+ static func color(fromHex raw: String?) -> Color? {
+ let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
+ guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
+ let r = Double((value >> 16) & 0xFF) / 255.0
+ let g = Double((value >> 8) & 0xFF) / 255.0
+ let b = Double(value & 0xFF) / 255.0
+ return Color(red: r, green: g, blue: b)
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift
index 4434443497e7..c7bda8cb6406 100644
--- a/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift
+++ b/apps/macos/Sources/OpenClaw/ConfigFileWatcher.swift
@@ -1,11 +1,11 @@
import Foundation
-final class ConfigFileWatcher: @unchecked Sendable {
+final class ConfigFileWatcher: @unchecked Sendable, SimpleFileWatcherOwner {
private let url: URL
private let watchedDir: URL
private let targetPath: String
private let targetName: String
- private let watcher: CoalescingFSEventsWatcher
+ let watcher: SimpleFileWatcher
init(url: URL, onChange: @escaping () -> Void) {
self.url = url
@@ -15,7 +15,7 @@ final class ConfigFileWatcher: @unchecked Sendable {
let watchedDirPath = self.watchedDir.path
let targetPath = self.targetPath
let targetName = self.targetName
- self.watcher = CoalescingFSEventsWatcher(
+ self.watcher = SimpleFileWatcher(CoalescingFSEventsWatcher(
paths: [watchedDirPath],
queueLabel: "ai.openclaw.configwatcher",
shouldNotify: { _, eventPaths in
@@ -28,18 +28,6 @@ final class ConfigFileWatcher: @unchecked Sendable {
}
return false
},
- onChange: onChange)
- }
-
- deinit {
- self.stop()
- }
-
- func start() {
- self.watcher.start()
- }
-
- func stop() {
- self.watcher.stop()
+ onChange: onChange))
}
}
diff --git a/apps/macos/Sources/OpenClaw/ConfigSettings.swift b/apps/macos/Sources/OpenClaw/ConfigSettings.swift
index 096ae3f71497..d5f3ee7343a4 100644
--- a/apps/macos/Sources/OpenClaw/ConfigSettings.swift
+++ b/apps/macos/Sources/OpenClaw/ConfigSettings.swift
@@ -72,7 +72,7 @@ extension ConfigSettings {
}
private var sidebar: some View {
- ScrollView {
+ SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 8) {
if self.sections.isEmpty {
Text("No config sections available.")
@@ -86,14 +86,7 @@ extension ConfigSettings {
}
}
}
- .padding(.vertical, 10)
- .padding(.horizontal, 10)
}
- .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color(nsColor: .windowBackgroundColor)))
- .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var detail: some View {
diff --git a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift
index f9a11b9e5129..7989afaeebcf 100644
--- a/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift
+++ b/apps/macos/Sources/OpenClaw/ContextMenuCardView.swift
@@ -6,10 +6,6 @@ struct ContextMenuCardView: View {
private let rows: [SessionRow]
private let statusText: String?
private let isLoading: Bool
- private let paddingTop: CGFloat = 8
- private let paddingBottom: CGFloat = 8
- private let paddingTrailing: CGFloat = 10
- private let paddingLeading: CGFloat = 20
private let barHeight: CGFloat = 3
init(
@@ -23,45 +19,32 @@ struct ContextMenuCardView: View {
}
var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- HStack(alignment: .firstTextBaseline) {
- Text("Context")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- Spacer(minLength: 10)
- Text(self.subtitle)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- if let statusText {
- Text(statusText)
- .font(.caption)
- .foregroundStyle(.secondary)
- } else if self.rows.isEmpty, !self.isLoading {
- Text("No active sessions")
- .font(.caption)
- .foregroundStyle(.secondary)
- } else {
- VStack(alignment: .leading, spacing: 12) {
- if self.rows.isEmpty, self.isLoading {
- ForEach(0..<2, id: \.self) { _ in
- self.placeholderRow
- }
- } else {
- ForEach(self.rows) { row in
- self.sessionRow(row)
+ MenuHeaderCard(
+ title: "Context",
+ subtitle: self.subtitle,
+ statusText: self.statusText,
+ paddingBottom: 8)
+ {
+ if self.statusText == nil {
+ if self.rows.isEmpty, !self.isLoading {
+ Text("No active sessions")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ VStack(alignment: .leading, spacing: 12) {
+ if self.rows.isEmpty, self.isLoading {
+ ForEach(0..<2, id: \.self) { _ in
+ self.placeholderRow
+ }
+ } else {
+ ForEach(self.rows) { row in
+ self.sessionRow(row)
+ }
}
}
}
}
}
- .padding(.top, self.paddingTop)
- .padding(.bottom, self.paddingBottom)
- .padding(.leading, self.paddingLeading)
- .padding(.trailing, self.paddingTrailing)
- .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
- .transaction { txn in txn.animation = nil }
}
private var subtitle: String {
diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift
index 16b4d6d3ad45..6fb81ce79417 100644
--- a/apps/macos/Sources/OpenClaw/ControlChannel.swift
+++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift
@@ -336,16 +336,8 @@ final class ControlChannel {
}
private func startEventStream() {
- self.eventTask?.cancel()
- self.eventTask = Task { [weak self] in
- guard let self else { return }
- let stream = await GatewayConnection.shared.subscribe()
- for await push in stream {
- if Task.isCancelled { return }
- await MainActor.run { [weak self] in
- self?.handle(push: push)
- }
- }
+ GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
+ self?.handle(push: push)
}
}
diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift
index 6b3fc85a7c0e..26b64ea7c655 100644
--- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift
+++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift
@@ -258,14 +258,6 @@ extension CronJobEditor {
}
func formatDuration(ms: Int) -> String {
- if ms < 1000 { return "\(ms)ms" }
- let s = Double(ms) / 1000.0
- if s < 60 { return "\(Int(round(s)))s" }
- let m = s / 60.0
- if m < 60 { return "\(Int(round(m)))m" }
- let h = m / 60.0
- if h < 48 { return "\(Int(round(h)))h" }
- let d = h / 24.0
- return "\(Int(round(d)))d"
+ DurationFormattingSupport.conciseDuration(ms: ms)
}
}
diff --git a/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/apps/macos/Sources/OpenClaw/CronJobsStore.swift
index 21c70ded5847..1dd5668cc9fa 100644
--- a/apps/macos/Sources/OpenClaw/CronJobsStore.swift
+++ b/apps/macos/Sources/OpenClaw/CronJobsStore.swift
@@ -38,7 +38,9 @@ final class CronJobsStore {
func start() {
guard !self.isPreview else { return }
guard self.eventTask == nil else { return }
- self.startGatewaySubscription()
+ GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
+ self?.handle(push: push)
+ }
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refreshJobs()
@@ -142,20 +144,6 @@ final class CronJobsStore {
// MARK: - Gateway events
- private func startGatewaySubscription() {
- self.eventTask?.cancel()
- self.eventTask = Task { [weak self] in
- guard let self else { return }
- let stream = await GatewayConnection.shared.subscribe()
- for await push in stream {
- if Task.isCancelled { return }
- await MainActor.run { [weak self] in
- self?.handle(push: push)
- }
- }
- }
- }
-
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "cron":
diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift
index c638e4c87b17..873b0741e341 100644
--- a/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift
+++ b/apps/macos/Sources/OpenClaw/CronSettings+Helpers.swift
@@ -31,15 +31,7 @@ extension CronSettings {
}
func formatDuration(ms: Int) -> String {
- if ms < 1000 { return "\(ms)ms" }
- let s = Double(ms) / 1000.0
- if s < 60 { return "\(Int(round(s)))s" }
- let m = s / 60.0
- if m < 60 { return "\(Int(round(m)))m" }
- let h = m / 60.0
- if h < 48 { return "\(Int(round(h)))h" }
- let d = h / 24.0
- return "\(Int(round(d)))d"
+ DurationFormattingSupport.conciseDuration(ms: ms)
}
func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
diff --git a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift
index f85e8d1a5df3..92ca57963377 100644
--- a/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift
+++ b/apps/macos/Sources/OpenClaw/DevicePairingApprovalPrompter.swift
@@ -17,9 +17,7 @@ final class DevicePairingApprovalPrompter {
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
- private var activeAlert: NSAlert?
- private var activeRequestId: String?
- private var alertHostWindow: NSWindow?
+ private let alertState = PairingAlertState()
private var resolvedByRequestId: Set = []
private struct PairingList: Codable {
@@ -55,48 +53,35 @@ final class DevicePairingApprovalPrompter {
}
}
- private struct PairingResolvedEvent: Codable {
- let requestId: String
- let deviceId: String
- let decision: String
- let ts: Double
- }
+ private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
- private enum PairingResolution: String {
- case approved
- case rejected
+ func start() {
+ self.startPushTask()
}
- func start() {
- guard self.task == nil else { return }
- self.isStopping = false
- self.task = Task { [weak self] in
- guard let self else { return }
- _ = try? await GatewayConnection.shared.refresh()
- await self.loadPendingRequestsFromGateway()
- let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
- for await push in stream {
- if Task.isCancelled { return }
- await MainActor.run { [weak self] in self?.handle(push: push) }
- }
- }
+ private func startPushTask() {
+ PairingAlertSupport.startPairingPushTask(
+ task: &self.task,
+ isStopping: &self.isStopping,
+ loadPending: self.loadPendingRequestsFromGateway,
+ handlePush: self.handle(push:))
}
func stop() {
- self.isStopping = true
- self.endActiveAlert()
- self.task?.cancel()
- self.task = nil
- self.queue.removeAll(keepingCapacity: false)
+ self.stopPushTask()
self.updatePendingCounts()
- self.isPresenting = false
- self.activeRequestId = nil
- self.alertHostWindow?.orderOut(nil)
- self.alertHostWindow?.close()
- self.alertHostWindow = nil
self.resolvedByRequestId.removeAll(keepingCapacity: false)
}
+ private func stopPushTask() {
+ PairingAlertSupport.stopPairingPrompter(
+ isStopping: &self.isStopping,
+ task: &self.task,
+ queue: &self.queue,
+ isPresenting: &self.isPresenting,
+ state: self.alertState)
+ }
+
private func loadPendingRequestsFromGateway() async {
do {
let list: PairingList = try await GatewayConnection.shared.requestDecoded(method: .devicePairList)
@@ -127,44 +112,13 @@ final class DevicePairingApprovalPrompter {
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting device pairing alert requestId=\(req.requestId, privacy: .public)")
- NSApp.activate(ignoringOtherApps: true)
-
- let alert = NSAlert()
- alert.alertStyle = .warning
- alert.messageText = "Allow device to connect?"
- alert.informativeText = Self.describe(req)
- alert.addButton(withTitle: "Later")
- alert.addButton(withTitle: "Approve")
- alert.addButton(withTitle: "Reject")
- if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
- alert.buttons[2].hasDestructiveAction = true
- }
-
- self.activeAlert = alert
- self.activeRequestId = req.requestId
- let hostWindow = self.requireAlertHostWindow()
-
- let sheetSize = alert.window.frame.size
- if let screen = hostWindow.screen ?? NSScreen.main {
- let bounds = screen.visibleFrame
- let x = bounds.midX - (sheetSize.width / 2)
- let sheetOriginY = bounds.midY - (sheetSize.height / 2)
- let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
- hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
- } else {
- hostWindow.center()
- }
-
- hostWindow.makeKeyAndOrderFront(nil)
- alert.beginSheetModal(for: hostWindow) { [weak self] response in
- Task { @MainActor [weak self] in
- guard let self else { return }
- self.activeRequestId = nil
- self.activeAlert = nil
- await self.handleAlertResponse(response, request: req)
- hostWindow.orderOut(nil)
- }
- }
+ PairingAlertSupport.presentPairingAlert(
+ request: req,
+ requestId: req.requestId,
+ messageText: "Allow device to connect?",
+ informativeText: Self.describe(req),
+ state: self.alertState,
+ onResponse: self.handleAlertResponse)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
@@ -206,33 +160,27 @@ final class DevicePairingApprovalPrompter {
}
private func approve(requestId: String) async -> Bool {
- do {
+ await PairingAlertSupport.approveRequest(
+ requestId: requestId,
+ kind: "device",
+ logger: self.logger)
+ {
try await GatewayConnection.shared.devicePairApprove(requestId: requestId)
- self.logger.info("approved device pairing requestId=\(requestId, privacy: .public)")
- return true
- } catch {
- self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
- self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
- return false
}
}
private func reject(requestId: String) async {
- do {
+ await PairingAlertSupport.rejectRequest(
+ requestId: requestId,
+ kind: "device",
+ logger: self.logger)
+ {
try await GatewayConnection.shared.devicePairReject(requestId: requestId)
- self.logger.info("rejected device pairing requestId=\(requestId, privacy: .public)")
- } catch {
- self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
- self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
private func endActiveAlert() {
- PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
- }
-
- private func requireAlertHostWindow() -> NSWindow {
- PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
+ PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
@@ -269,9 +217,10 @@ final class DevicePairingApprovalPrompter {
}
private func handleResolved(_ resolved: PairingResolvedEvent) {
- let resolution = resolved.decision == PairingResolution.approved.rawValue ? PairingResolution
- .approved : .rejected
- if let activeRequestId, activeRequestId == resolved.requestId {
+ let resolution = resolved.decision == PairingAlertSupport.PairingResolution.approved.rawValue
+ ? PairingAlertSupport.PairingResolution.approved
+ : PairingAlertSupport.PairingResolution.rejected
+ if let activeRequestId = self.alertState.activeRequestId, activeRequestId == resolved.requestId {
self.resolvedByRequestId.insert(resolved.requestId)
self.endActiveAlert()
let decision = resolution.rawValue
diff --git a/apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift b/apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift
new file mode 100644
index 000000000000..7ca706867c3e
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/DurationFormattingSupport.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+enum DurationFormattingSupport {
+ static func conciseDuration(ms: Int) -> String {
+ if ms < 1000 { return "\(ms)ms" }
+ let s = Double(ms) / 1000.0
+ if s < 60 { return "\(Int(round(s)))s" }
+ let m = s / 60.0
+ if m < 60 { return "\(Int(round(m)))m" }
+ let h = m / 60.0
+ if h < 48 { return "\(Int(round(h)))h" }
+ let d = h / 24.0
+ return "\(Int(round(d)))d"
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift
index 670fa891c5b1..0da8faadbc4c 100644
--- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift
+++ b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift
@@ -19,15 +19,13 @@ final class ExecApprovalsGatewayPrompter {
}
func start() {
- guard self.task == nil else { return }
- self.task = Task { [weak self] in
+ SimpleTaskSupport.start(task: &self.task) { [weak self] in
await self?.run()
}
}
func stop() {
- self.task?.cancel()
- self.task = nil
+ SimpleTaskSupport.stop(task: &self.task)
}
private func run() async {
diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift
index 390900eea72e..bee77ce3e7dd 100644
--- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift
+++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift
@@ -73,6 +73,22 @@ private struct ExecHostResponse: Codable {
var error: ExecHostError?
}
+private func readLineFromHandle(_ handle: FileHandle, maxBytes: Int) throws -> String? {
+ var buffer = Data()
+ while buffer.count < maxBytes {
+ let chunk = try handle.read(upToCount: 4096) ?? Data()
+ if chunk.isEmpty { break }
+ buffer.append(chunk)
+ if buffer.contains(0x0A) { break }
+ }
+ guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
+ guard !buffer.isEmpty else { return nil }
+ return String(data: buffer, encoding: .utf8)
+ }
+ let lineData = buffer.subdata(in: 0.. String? {
- var buffer = Data()
- while buffer.count < maxBytes {
- let chunk = try handle.read(upToCount: 4096) ?? Data()
- if chunk.isEmpty { break }
- buffer.append(chunk)
- if buffer.contains(0x0A) { break }
- }
- guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
- guard !buffer.isEmpty else { return nil }
- return String(data: buffer, encoding: .utf8)
- }
- let lineData = buffer.subdata(in: 0.. String? {
- var buffer = Data()
- while buffer.count < maxBytes {
- let chunk = try handle.read(upToCount: 4096) ?? Data()
- if chunk.isEmpty { break }
- buffer.append(chunk)
- if buffer.contains(0x0A) { break }
- }
- guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
- guard !buffer.isEmpty else { return nil }
- return String(data: buffer, encoding: .utf8)
- }
- let lineData = buffer.subdata(in: 0.. Bool {
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
return token.range(of: pattern, options: .regularExpression) != nil
@@ -55,11 +42,11 @@ enum ExecEnvInvocationUnwrapper {
if token.hasPrefix("-"), token != "-" {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
- if self.flagOptions.contains(flag) {
+ if ExecEnvOptions.flagOnly.contains(flag) {
idx += 1
continue
}
- if self.optionsWithValue.contains(flag) {
+ if ExecEnvOptions.withValue.contains(flag) {
if !lower.contains("=") {
expectsOptionValue = true
}
diff --git a/apps/macos/Sources/OpenClaw/ExecEnvOptions.swift b/apps/macos/Sources/OpenClaw/ExecEnvOptions.swift
new file mode 100644
index 000000000000..d8dae4f8ca49
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/ExecEnvOptions.swift
@@ -0,0 +1,29 @@
+import Foundation
+
+enum ExecEnvOptions {
+ static let withValue = Set([
+ "-u",
+ "--unset",
+ "-c",
+ "--chdir",
+ "-s",
+ "--split-string",
+ "--default-signal",
+ "--ignore-signal",
+ "--block-signal",
+ ])
+
+ static let flagOnly = Set(["-i", "--ignore-environment", "-0", "--null"])
+
+ static let inlineValuePrefixes = [
+ "-u",
+ "-c",
+ "-s",
+ "--unset=",
+ "--chdir=",
+ "--split-string=",
+ "--default-signal=",
+ "--ignore-signal=",
+ "--block-signal=",
+ ]
+}
diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift
index 707a46322d8f..f8ff84155e13 100644
--- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift
+++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift
@@ -39,30 +39,6 @@ enum ExecSystemRunCommandValidator {
private static let posixInlineCommandFlags = Set(["-lc", "-c", "--command"])
private static let powershellInlineCommandFlags = Set(["-c", "-command", "--command"])
- private static let envOptionsWithValue = Set([
- "-u",
- "--unset",
- "-c",
- "--chdir",
- "-s",
- "--split-string",
- "--default-signal",
- "--ignore-signal",
- "--block-signal",
- ])
- private static let envFlagOptions = Set(["-i", "--ignore-environment", "-0", "--null"])
- private static let envInlineValuePrefixes = [
- "-u",
- "-c",
- "-s",
- "--unset=",
- "--chdir=",
- "--split-string=",
- "--default-signal=",
- "--ignore-signal=",
- "--block-signal=",
- ]
-
private struct EnvUnwrapResult {
let argv: [String]
let usesModifiers: Bool
@@ -113,7 +89,7 @@ enum ExecSystemRunCommandValidator {
}
private static func hasEnvInlineValuePrefix(_ lowerToken: String) -> Bool {
- self.envInlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
+ ExecEnvOptions.inlineValuePrefixes.contains { lowerToken.hasPrefix($0) }
}
private static func unwrapEnvInvocationWithMetadata(_ argv: [String]) -> EnvUnwrapResult? {
@@ -148,12 +124,12 @@ enum ExecSystemRunCommandValidator {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
- if self.envFlagOptions.contains(flag) {
+ if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
- if self.envOptionsWithValue.contains(flag) {
+ if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
@@ -301,10 +277,15 @@ enum ExecSystemRunCommandValidator {
return current
}
- private static func resolveInlineCommandTokenIndex(
+ private struct InlineCommandTokenMatch {
+ var tokenIndex: Int
+ var inlineCommand: String?
+ }
+
+ private static func findInlineCommandTokenMatch(
_ argv: [String],
flags: Set,
- allowCombinedC: Bool) -> Int?
+ allowCombinedC: Bool) -> InlineCommandTokenMatch?
{
var idx = 1
while idx < argv.count {
@@ -318,21 +299,35 @@ enum ExecSystemRunCommandValidator {
break
}
if flags.contains(lower) {
- return idx + 1 < argv.count ? idx + 1 : nil
+ return InlineCommandTokenMatch(tokenIndex: idx, inlineCommand: nil)
}
if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
let inline = String(token.dropFirst(inlineOffset))
.trimmingCharacters(in: .whitespacesAndNewlines)
- if !inline.isEmpty {
- return idx
- }
- return idx + 1 < argv.count ? idx + 1 : nil
+ return InlineCommandTokenMatch(
+ tokenIndex: idx,
+ inlineCommand: inline.isEmpty ? nil : inline)
}
idx += 1
}
return nil
}
+ private static func resolveInlineCommandTokenIndex(
+ _ argv: [String],
+ flags: Set,
+ allowCombinedC: Bool) -> Int?
+ {
+ guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
+ return nil
+ }
+ if match.inlineCommand != nil {
+ return match.tokenIndex
+ }
+ let nextIndex = match.tokenIndex + 1
+ return nextIndex < argv.count ? nextIndex : nil
+ }
+
private static func combinedCommandInlineOffset(_ token: String) -> Int? {
let chars = Array(token.lowercased())
guard chars.count >= 2, chars[0] == "-", chars[1] != "-" else {
@@ -371,30 +366,14 @@ enum ExecSystemRunCommandValidator {
flags: Set,
allowCombinedC: Bool) -> String?
{
- var idx = 1
- while idx < argv.count {
- let token = argv[idx].trimmingCharacters(in: .whitespacesAndNewlines)
- if token.isEmpty {
- idx += 1
- continue
- }
- let lower = token.lowercased()
- if lower == "--" {
- break
- }
- if flags.contains(lower) {
- return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
- }
- if allowCombinedC, let inlineOffset = self.combinedCommandInlineOffset(token) {
- let inline = String(token.dropFirst(inlineOffset))
- if let inlineValue = self.trimmedNonEmpty(inline) {
- return inlineValue
- }
- return self.trimmedNonEmpty(idx + 1 < argv.count ? argv[idx + 1] : nil)
- }
- idx += 1
+ guard let match = self.findInlineCommandTokenMatch(argv, flags: flags, allowCombinedC: allowCombinedC) else {
+ return nil
}
- return nil
+ if let inlineCommand = match.inlineCommand {
+ return inlineCommand
+ }
+ let nextIndex = match.tokenIndex + 1
+ return self.trimmedNonEmpty(nextIndex < argv.count ? argv[nextIndex] : nil)
}
private static func extractCmdInlineCommand(_ argv: [String]) -> String? {
diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift
index babab5866fd4..f45e4301abc6 100644
--- a/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayDiscoveryMenu.swift
@@ -48,27 +48,11 @@ struct GatewayDiscoveryInlineList: View {
.truncationMode(.middle)
}
Spacer(minLength: 0)
- if selected {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(Color.accentColor)
- } else {
- Image(systemName: "arrow.right.circle")
- .foregroundStyle(.secondary)
- }
+ SelectionStateIndicator(selected: selected)
}
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(self.rowBackground(
- selected: selected,
- hovered: self.hoveredGatewayID == gateway.id)))
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(
- selected ? Color.accentColor.opacity(0.45) : Color.clear,
- lineWidth: 1))
+ .openClawSelectableRowChrome(
+ selected: selected,
+ hovered: self.hoveredGatewayID == gateway.id)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
@@ -106,12 +90,6 @@ struct GatewayDiscoveryInlineList: View {
}
}
- private func rowBackground(selected: Bool, hovered: Bool) -> Color {
- if selected { return Color.accentColor.opacity(0.12) }
- if hovered { return Color.secondary.opacity(0.08) }
- return Color.clear
- }
-
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
diff --git a/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift
new file mode 100644
index 000000000000..ea7492b2c79f
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/GatewayDiscoverySelectionSupport.swift
@@ -0,0 +1,22 @@
+import OpenClawDiscovery
+
+@MainActor
+enum GatewayDiscoverySelectionSupport {
+ static func applyRemoteSelection(
+ gateway: GatewayDiscoveryModel.DiscoveredGateway,
+ state: AppState)
+ {
+ if state.remoteTransport == .direct {
+ state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
+ } else {
+ state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
+ }
+ if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
+ OpenClawConfigFile.setRemoteGatewayUrl(
+ host: endpoint.host,
+ port: endpoint.port)
+ } else {
+ OpenClawConfigFile.clearRemoteGatewayUrl()
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
index 0edb2e651225..141b7c43685c 100644
--- a/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayEndpointStore.swift
@@ -347,21 +347,8 @@ actor GatewayEndpointStore {
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
func ensureRemoteControlTunnel() async throws -> UInt16 {
- let mode = await self.deps.mode()
- guard mode == .remote else {
- throw NSError(
- domain: "RemoteTunnel",
- code: 1,
- userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
- }
- let root = OpenClawConfigFile.loadDict()
- if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
- guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
- throw NSError(
- domain: "GatewayEndpoint",
- code: 1,
- userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
- }
+ try await self.requireRemoteMode()
+ if let url = try self.resolveDirectRemoteURL() {
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
@@ -425,22 +412,9 @@ actor GatewayEndpointStore {
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
- let mode = await self.deps.mode()
- guard mode == .remote else {
- throw NSError(
- domain: "RemoteTunnel",
- code: 1,
- userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
- }
+ try await self.requireRemoteMode()
- let root = OpenClawConfigFile.loadDict()
- if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
- guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
- throw NSError(
- domain: "GatewayEndpoint",
- code: 1,
- userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
- }
+ if let url = try self.resolveDirectRemoteURL() {
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
@@ -491,6 +465,27 @@ actor GatewayEndpointStore {
}
}
+ private func requireRemoteMode() async throws {
+ guard await self.deps.mode() == .remote else {
+ throw NSError(
+ domain: "RemoteTunnel",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
+ }
+ }
+
+ private func resolveDirectRemoteURL() throws -> URL? {
+ let root = OpenClawConfigFile.loadDict()
+ guard GatewayRemoteConfig.resolveTransport(root: root) == .direct else { return nil }
+ guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
+ throw NSError(
+ domain: "GatewayEndpoint",
+ code: 1,
+ userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
+ }
+ return url
+ }
+
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift
index 98743fec8b36..bc57055fb61d 100644
--- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift
@@ -180,25 +180,11 @@ extension GatewayLaunchAgentManager {
}
private static func parseDaemonJson(from raw: String) -> ParsedDaemonJson? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let start = trimmed.firstIndex(of: "{"),
- let end = trimmed.lastIndex(of: "}")
- else {
- return nil
- }
- let jsonText = String(trimmed[start...end])
- guard let data = jsonText.data(using: .utf8) else { return nil }
- guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
- return ParsedDaemonJson(text: jsonText, object: object)
+ guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
+ return ParsedDaemonJson(text: parsed.text, object: parsed.object)
}
private static func summarize(_ text: String) -> String? {
- let lines = text
- .split(whereSeparator: \.isNewline)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- guard let last = lines.last else { return nil }
- let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
- return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
+ TextSummarySupport.summarizeLastLine(text)
}
}
diff --git a/apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift b/apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift
new file mode 100644
index 000000000000..3b3058e17299
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/GatewayPushSubscription.swift
@@ -0,0 +1,34 @@
+import OpenClawKit
+
+enum GatewayPushSubscription {
+ @MainActor
+ static func consume(
+ bufferingNewest: Int? = nil,
+ onPush: @escaping @MainActor (GatewayPush) -> Void) async
+ {
+ let stream: AsyncStream = if let bufferingNewest {
+ await GatewayConnection.shared.subscribe(bufferingNewest: bufferingNewest)
+ } else {
+ await GatewayConnection.shared.subscribe()
+ }
+
+ for await push in stream {
+ if Task.isCancelled { return }
+ await MainActor.run {
+ onPush(push)
+ }
+ }
+ }
+
+ @MainActor
+ static func restartTask(
+ task: inout Task?,
+ bufferingNewest: Int? = nil,
+ onPush: @escaping @MainActor (GatewayPush) -> Void)
+ {
+ task?.cancel()
+ task = Task {
+ await self.consume(bufferingNewest: bufferingNewest, onPush: onPush)
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift
index 64a6f92db8fa..3d044bcda2ff 100644
--- a/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift
+++ b/apps/macos/Sources/OpenClaw/GatewayRemoteConfig.swift
@@ -1,41 +1,7 @@
import Foundation
-import Network
+import OpenClawKit
enum GatewayRemoteConfig {
- private static func isLoopbackHost(_ rawHost: String) -> Bool {
- var host = rawHost
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- .trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
- if host.hasSuffix(".") {
- host.removeLast()
- }
- if let zoneIndex = host.firstIndex(of: "%") {
- host = String(host[.. AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
@@ -74,7 +40,7 @@ enum GatewayRemoteConfig {
guard scheme == "ws" || scheme == "wss" else { return nil }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
- if scheme == "ws", !self.isLoopbackHost(host) {
+ if scheme == "ws", !LoopbackHost.isLoopbackHost(host) {
return nil
}
if scheme == "ws", url.port == nil {
diff --git a/apps/macos/Sources/OpenClaw/GeneralSettings.swift b/apps/macos/Sources/OpenClaw/GeneralSettings.swift
index 4dae858771cc..bdf02d949924 100644
--- a/apps/macos/Sources/OpenClaw/GeneralSettings.swift
+++ b/apps/macos/Sources/OpenClaw/GeneralSettings.swift
@@ -260,17 +260,7 @@ struct GeneralSettings: View {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
- Button {
- Task { await self.testRemote() }
- } label: {
- if self.remoteStatus == .checking {
- ProgressView().controlSize(.small)
- } else {
- Text("Test remote")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.remoteStatus == .checking || !canTest)
+ self.remoteTestButton(disabled: !canTest)
}
if let validationMessage {
Text(validationMessage)
@@ -290,18 +280,8 @@ struct GeneralSettings: View {
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
- Button {
- Task { await self.testRemote() }
- } label: {
- if self.remoteStatus == .checking {
- ProgressView().controlSize(.small)
- } else {
- Text("Test remote")
- }
- }
- .buttonStyle(.borderedProminent)
- .disabled(self.remoteStatus == .checking || self.state.remoteUrl
- .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ self.remoteTestButton(
+ disabled: self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(
"Direct mode requires wss:// for remote hosts. ws:// is only allowed for localhost/127.0.0.1.")
@@ -311,6 +291,20 @@ struct GeneralSettings: View {
}
}
+ private func remoteTestButton(disabled: Bool) -> some View {
+ Button {
+ Task { await self.testRemote() }
+ } label: {
+ if self.remoteStatus == .checking {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("Test remote")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(self.remoteStatus == .checking || disabled)
+ }
+
private var controlStatusLine: String {
switch ControlChannel.shared.state {
case .connected: "Connected"
@@ -672,19 +666,7 @@ extension GeneralSettings {
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
-
- if self.state.remoteTransport == .direct {
- self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
- } else {
- self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
- }
- if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
- OpenClawConfigFile.setRemoteGatewayUrl(
- host: endpoint.host,
- port: endpoint.port)
- } else {
- OpenClawConfigFile.clearRemoteGatewayUrl()
- }
+ GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
}
}
diff --git a/apps/macos/Sources/OpenClaw/HoverHUD.swift b/apps/macos/Sources/OpenClaw/HoverHUD.swift
index d3482362a0f4..f9a8625ab2ca 100644
--- a/apps/macos/Sources/OpenClaw/HoverHUD.swift
+++ b/apps/macos/Sources/OpenClaw/HoverHUD.swift
@@ -100,17 +100,8 @@ final class HoverHUDController {
return
}
- let target = window.frame.offsetBy(dx: 0, dy: 6)
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.14
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 0
- } completionHandler: {
- Task { @MainActor in
- window.orderOut(nil)
- self.model.isVisible = false
- }
+ OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) {
+ self.model.isVisible = false
}
}
@@ -140,15 +131,7 @@ final class HoverHUDController {
if !self.model.isVisible {
self.model.isVisible = true
let start = target.offsetBy(dx: 0, dy: 8)
- window.setFrame(start, display: true)
- window.alphaValue = 0
- window.orderFrontRegardless()
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 1
- }
+ OverlayPanelFactory.animatePresent(window: window, from: start, to: target)
} else {
window.orderFrontRegardless()
self.updateWindowFrame(animate: true)
@@ -157,22 +140,10 @@ final class HoverHUDController {
private func ensureWindow() {
if self.window != nil { return }
- let panel = NSPanel(
+ let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false)
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.hasShadow = true
- panel.level = .statusBar
- panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
- panel.hidesOnDeactivate = false
- panel.isMovable = false
- panel.isFloatingPanel = true
- panel.becomesKeyOnlyIfNeeded = true
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
+ level: .statusBar,
+ hasShadow: true)
let host = NSHostingView(rootView: HoverHUDView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
@@ -201,17 +172,7 @@ final class HoverHUDController {
}
private func updateWindowFrame(animate: Bool = false) {
- guard let window else { return }
- let frame = self.targetFrame()
- if animate {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.12
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(frame, display: true)
- }
- } else {
- window.setFrame(frame, display: true)
- }
+ OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
private func installDismissMonitor() {
@@ -231,10 +192,7 @@ final class HoverHUDController {
}
private func removeDismissMonitor() {
- if let monitor = self.dismissMonitor {
- NSEvent.removeMonitor(monitor)
- self.dismissMonitor = nil
- }
+ OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
}
diff --git a/apps/macos/Sources/OpenClaw/InstancesSettings.swift b/apps/macos/Sources/OpenClaw/InstancesSettings.swift
index 0c992c6970fa..8949ae1b037f 100644
--- a/apps/macos/Sources/OpenClaw/InstancesSettings.swift
+++ b/apps/macos/Sources/OpenClaw/InstancesSettings.swift
@@ -43,16 +43,8 @@ struct InstancesSettings: View {
.foregroundStyle(.secondary)
}
Spacer()
- if self.store.isLoading {
- ProgressView()
- } else {
- Button {
- Task { await self.store.refresh() }
- } label: {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
- .buttonStyle(.bordered)
- .help("Refresh")
+ SettingsRefreshButton(isLoading: self.store.isLoading) {
+ Task { await self.store.refresh() }
}
}
}
@@ -276,7 +268,7 @@ struct InstancesSettings: View {
}
private func platformIcon(_ raw: String) -> String {
- let (prefix, _) = self.parsePlatform(raw)
+ let (prefix, _) = PlatformLabelFormatter.parse(raw)
switch prefix {
case "macos":
return "laptopcomputer"
@@ -294,31 +286,7 @@ struct InstancesSettings: View {
}
private func prettyPlatform(_ raw: String) -> String? {
- let (prefix, version) = self.parsePlatform(raw)
- if prefix.isEmpty { return nil }
- let name: String = switch prefix {
- case "macos": "macOS"
- case "ios": "iOS"
- case "ipados": "iPadOS"
- case "tvos": "tvOS"
- case "watchos": "watchOS"
- default: prefix.prefix(1).uppercased() + prefix.dropFirst()
- }
- guard let version, !version.isEmpty else { return name }
- let parts = version.split(separator: ".").map(String.init)
- if parts.count >= 2 {
- return "\(name) \(parts[0]).\(parts[1])"
- }
- return "\(name) \(version)"
- }
-
- private func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty { return ("", nil) }
- let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
- let prefix = parts.first?.lowercased() ?? ""
- let versionToken = parts.dropFirst().first
- return (prefix, versionToken)
+ PlatformLabelFormatter.pretty(raw)
}
private func presenceUpdateSourceShortText(_ reason: String) -> String? {
@@ -450,8 +418,8 @@ extension InstancesSettings {
_ = view.prettyPlatform("ipados 17.1")
_ = view.prettyPlatform("linux")
_ = view.prettyPlatform(" ")
- _ = view.parsePlatform("macOS 14.1")
- _ = view.parsePlatform(" ")
+ _ = PlatformLabelFormatter.parse("macOS 14.1")
+ _ = PlatformLabelFormatter.parse(" ")
_ = view.presenceUpdateSourceShortText("self")
_ = view.presenceUpdateSourceShortText("instances-refresh")
_ = view.presenceUpdateSourceShortText("seq gap")
diff --git a/apps/macos/Sources/OpenClaw/InstancesStore.swift b/apps/macos/Sources/OpenClaw/InstancesStore.swift
index 566340337db6..073d129b944b 100644
--- a/apps/macos/Sources/OpenClaw/InstancesStore.swift
+++ b/apps/macos/Sources/OpenClaw/InstancesStore.swift
@@ -62,14 +62,11 @@ final class InstancesStore {
self.startCount += 1
guard self.startCount == 1 else { return }
guard self.task == nil else { return }
- self.startGatewaySubscription()
- self.task = Task.detached { [weak self] in
- guard let self else { return }
- await self.refresh()
- while !Task.isCancelled {
- try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
- await self.refresh()
- }
+ GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in
+ self?.handle(push: push)
+ }
+ SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
+ await self?.refresh()
}
}
@@ -84,20 +81,6 @@ final class InstancesStore {
self.eventTask = nil
}
- private func startGatewaySubscription() {
- self.eventTask?.cancel()
- self.eventTask = Task { [weak self] in
- guard let self else { return }
- let stream = await GatewayConnection.shared.subscribe()
- for await push in stream {
- if Task.isCancelled { return }
- await MainActor.run { [weak self] in
- self?.handle(push: push)
- }
- }
- }
- }
-
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "presence":
diff --git a/apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift b/apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift
new file mode 100644
index 000000000000..f13570f6f718
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/JSONObjectExtractionSupport.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+enum JSONObjectExtractionSupport {
+ static func extract(from raw: String) -> (text: String, object: [String: Any])? {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let start = trimmed.firstIndex(of: "{"),
+ let end = trimmed.lastIndex(of: "}")
+ else {
+ return nil
+ }
+ let jsonText = String(trimmed[start...end])
+ guard let data = jsonText.data(using: .utf8) else { return nil }
+ guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
+ return (jsonText, object)
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift
index 7692887e6c7e..95cbe7fe84e3 100644
--- a/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift
+++ b/apps/macos/Sources/OpenClaw/Logging/OpenClawLogging.swift
@@ -98,23 +98,42 @@ extension Logger.Message.StringInterpolation {
}
}
-struct OpenClawOSLogHandler: LogHandler {
- private let osLogger: os.Logger
- var metadata: Logger.Metadata = [:]
+private func stringifyLogMetadataValue(_ value: Logger.Metadata.Value) -> String {
+ switch value {
+ case let .string(text):
+ text
+ case let .stringConvertible(value):
+ String(describing: value)
+ case let .array(values):
+ "[" + values.map { stringifyLogMetadataValue($0) }.joined(separator: ",") + "]"
+ case let .dictionary(entries):
+ "{" + entries.map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }.joined(separator: ",") + "}"
+ }
+}
+private protocol AppLogLevelBackedHandler: LogHandler {
+ var metadata: Logger.Metadata { get set }
+}
+
+extension AppLogLevelBackedHandler {
var logLevel: Logger.Level {
get { AppLogSettings.logLevel() }
set { AppLogSettings.setLogLevel(newValue) }
}
- init(subsystem: String, category: String) {
- self.osLogger = os.Logger(subsystem: subsystem, category: category)
- }
-
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
get { self.metadata[key] }
set { self.metadata[key] = newValue }
}
+}
+
+struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
+ private let osLogger: os.Logger
+ var metadata: Logger.Metadata = [:]
+
+ init(subsystem: String, category: String) {
+ self.osLogger = os.Logger(subsystem: subsystem, category: category)
+ }
func log(
level: Logger.Level,
@@ -157,39 +176,16 @@ struct OpenClawOSLogHandler: LogHandler {
guard !metadata.isEmpty else { return message.description }
let meta = metadata
.sorted(by: { $0.key < $1.key })
- .map { "\($0.key)=\(self.stringify($0.value))" }
+ .map { "\($0.key)=\(stringifyLogMetadataValue($0.value))" }
.joined(separator: " ")
return "\(message.description) [\(meta)]"
}
-
- private static func stringify(_ value: Logger.Metadata.Value) -> String {
- switch value {
- case let .string(text):
- text
- case let .stringConvertible(value):
- String(describing: value)
- case let .array(values):
- "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
- case let .dictionary(entries):
- "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
- }
- }
}
-struct OpenClawFileLogHandler: LogHandler {
+struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
let label: String
var metadata: Logger.Metadata = [:]
- var logLevel: Logger.Level {
- get { AppLogSettings.logLevel() }
- set { AppLogSettings.setLogLevel(newValue) }
- }
-
- subscript(metadataKey key: String) -> Logger.Metadata.Value? {
- get { self.metadata[key] }
- set { self.metadata[key] = newValue }
- }
-
func log(
level: Logger.Level,
message: Logger.Message,
@@ -212,21 +208,8 @@ struct OpenClawFileLogHandler: LogHandler {
]
let merged = self.metadata.merging(metadata ?? [:], uniquingKeysWith: { _, new in new })
for (key, value) in merged {
- fields["meta.\(key)"] = Self.stringify(value)
+ fields["meta.\(key)"] = stringifyLogMetadataValue(value)
}
DiagnosticsFileLog.shared.log(category: category, event: message.description, fields: fields)
}
-
- private static func stringify(_ value: Logger.Metadata.Value) -> String {
- switch value {
- case let .string(text):
- text
- case let .stringConvertible(value):
- String(describing: value)
- case let .array(values):
- "[" + values.map { self.stringify($0) }.joined(separator: ",") + "]"
- case let .dictionary(entries):
- "{" + entries.map { "\($0.key)=\(self.stringify($0.value))" }.joined(separator: ",") + "}"
- }
- }
}
diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift
index d7ab72ce86f6..0750da56a5ea 100644
--- a/apps/macos/Sources/OpenClaw/MenuBar.swift
+++ b/apps/macos/Sources/OpenClaw/MenuBar.swift
@@ -228,17 +228,7 @@ private final class StatusItemMouseHandlerView: NSView {
override func updateTrackingAreas() {
super.updateTrackingAreas()
- if let tracking {
- self.removeTrackingArea(tracking)
- }
- let options: NSTrackingArea.Options = [
- .mouseEnteredAndExited,
- .activeAlways,
- .inVisibleRect,
- ]
- let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
- self.addTrackingArea(area)
- self.tracking = area
+ TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
}
override func mouseEntered(with event: NSEvent) {
diff --git a/apps/macos/Sources/OpenClaw/MenuContentView.swift b/apps/macos/Sources/OpenClaw/MenuContentView.swift
index 3416d23f8121..f4a250aabe41 100644
--- a/apps/macos/Sources/OpenClaw/MenuContentView.swift
+++ b/apps/macos/Sources/OpenClaw/MenuContentView.swift
@@ -170,7 +170,11 @@ struct MenuContent: View {
await self.loadBrowserControlEnabled()
}
.onAppear {
- self.startMicObserver()
+ MicRefreshSupport.startObserver(self.micObserver) {
+ MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
+ await self.loadMicrophones(force: true)
+ }
+ }
}
.onDisappear {
self.micRefreshTask?.cancel()
@@ -425,11 +429,7 @@ struct MenuContent: View {
}
private var voiceWakeBinding: Binding {
- Binding(
- get: { self.state.swabbleEnabled },
- set: { newValue in
- Task { await self.state.setVoiceWakeEnabled(newValue) }
- })
+ MicRefreshSupport.voiceWakeBinding(for: self.state)
}
private var showVoiceWakeMicPicker: Bool {
@@ -546,46 +546,20 @@ struct MenuContent: View {
}
.map { AudioInputDevice(uid: $0.uniqueID, name: $0.localizedName) }
self.availableMics = self.filterAliveInputs(self.availableMics)
- self.updateSelectedMicName()
+ self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
+ selectedID: self.state.voiceWakeMicID,
+ in: self.availableMics,
+ uid: \.uid,
+ name: \.name)
self.loadingMics = false
}
- private func startMicObserver() {
- self.micObserver.start {
- Task { @MainActor in
- self.scheduleMicRefresh()
- }
- }
- }
-
- @MainActor
- private func scheduleMicRefresh() {
- self.micRefreshTask?.cancel()
- self.micRefreshTask = Task { @MainActor in
- try? await Task.sleep(nanoseconds: 300_000_000)
- guard !Task.isCancelled else { return }
- await self.loadMicrophones(force: true)
- }
- }
-
private func filterAliveInputs(_ inputs: [AudioInputDevice]) -> [AudioInputDevice] {
let aliveUIDs = AudioInputDeviceObserver.aliveInputDeviceUIDs()
guard !aliveUIDs.isEmpty else { return inputs }
return inputs.filter { aliveUIDs.contains($0.uid) }
}
- @MainActor
- private func updateSelectedMicName() {
- let selected = self.state.voiceWakeMicID
- if selected.isEmpty {
- self.state.voiceWakeMicName = ""
- return
- }
- if let match = self.availableMics.first(where: { $0.uid == selected }) {
- self.state.voiceWakeMicName = match.name
- }
- }
-
private struct AudioInputDevice: Identifiable, Equatable {
let uid: String
let name: String
diff --git a/apps/macos/Sources/OpenClaw/MenuHeaderCard.swift b/apps/macos/Sources/OpenClaw/MenuHeaderCard.swift
new file mode 100644
index 000000000000..baf0d78c295d
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/MenuHeaderCard.swift
@@ -0,0 +1,52 @@
+import SwiftUI
+
+struct MenuHeaderCard: View {
+ let title: String
+ let subtitle: String
+ let statusText: String?
+ let paddingBottom: CGFloat
+ @ViewBuilder var content: Content
+
+ init(
+ title: String,
+ subtitle: String,
+ statusText: String? = nil,
+ paddingBottom: CGFloat = 6,
+ @ViewBuilder content: () -> Content = { EmptyView() })
+ {
+ self.title = title
+ self.subtitle = subtitle
+ self.statusText = statusText
+ self.paddingBottom = paddingBottom
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(self.title)
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer(minLength: 10)
+ Text(self.subtitle)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ if let statusText, !statusText.isEmpty {
+ Text(statusText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ }
+ self.content
+ }
+ .padding(.top, 8)
+ .padding(.bottom, self.paddingBottom)
+ .padding(.leading, 20)
+ .padding(.trailing, 10)
+ .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
+ .transaction { txn in txn.animation = nil }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift
index 7107946989ec..d6f0cfb981fe 100644
--- a/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift
+++ b/apps/macos/Sources/OpenClaw/MenuHighlightedHostView.swift
@@ -33,17 +33,7 @@ final class HighlightedMenuItemHostView: NSView {
override func updateTrackingAreas() {
super.updateTrackingAreas()
- if let tracking {
- self.removeTrackingArea(tracking)
- }
- let options: NSTrackingArea.Options = [
- .mouseEnteredAndExited,
- .activeAlways,
- .inVisibleRect,
- ]
- let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
- self.addTrackingArea(area)
- self.tracking = area
+ TrackingAreaSupport.resetMouseTracking(on: self, tracking: &self.tracking, owner: self)
}
override func mouseEntered(with event: NSEvent) {
diff --git a/apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift b/apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift
new file mode 100644
index 000000000000..6d494828409d
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/MenuItemHighlightColors.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+enum MenuItemHighlightColors {
+ struct Palette {
+ let primary: Color
+ let secondary: Color
+ }
+
+ static func primary(_ highlighted: Bool) -> Color {
+ highlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
+ }
+
+ static func secondary(_ highlighted: Bool) -> Color {
+ highlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
+ }
+
+ static func palette(_ highlighted: Bool) -> Palette {
+ Palette(
+ primary: self.primary(highlighted),
+ secondary: self.secondary(highlighted))
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift b/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift
index e96cea53b843..2057ddc3aebc 100644
--- a/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift
+++ b/apps/macos/Sources/OpenClaw/MenuSessionsHeaderView.swift
@@ -4,37 +4,11 @@ struct MenuSessionsHeaderView: View {
let count: Int
let statusText: String?
- private let paddingTop: CGFloat = 8
- private let paddingBottom: CGFloat = 6
- private let paddingTrailing: CGFloat = 10
- private let paddingLeading: CGFloat = 20
-
var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- HStack(alignment: .firstTextBaseline) {
- Text("Context")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- Spacer(minLength: 10)
- Text(self.subtitle)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
-
- if let statusText, !statusText.isEmpty {
- Text(statusText)
- .font(.caption)
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.tail)
- }
- }
- .padding(.top, self.paddingTop)
- .padding(.bottom, self.paddingBottom)
- .padding(.leading, self.paddingLeading)
- .padding(.trailing, self.paddingTrailing)
- .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
- .transaction { txn in txn.animation = nil }
+ MenuHeaderCard(
+ title: "Context",
+ subtitle: self.subtitle,
+ statusText: self.statusText)
}
private var subtitle: String {
diff --git a/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift b/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift
index dbb717d690a6..cd7b4ede5ef1 100644
--- a/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift
+++ b/apps/macos/Sources/OpenClaw/MenuUsageHeaderView.swift
@@ -3,29 +3,10 @@ import SwiftUI
struct MenuUsageHeaderView: View {
let count: Int
- private let paddingTop: CGFloat = 8
- private let paddingBottom: CGFloat = 6
- private let paddingTrailing: CGFloat = 10
- private let paddingLeading: CGFloat = 20
-
var body: some View {
- VStack(alignment: .leading, spacing: 6) {
- HStack(alignment: .firstTextBaseline) {
- Text("Usage")
- .font(.caption.weight(.semibold))
- .foregroundStyle(.secondary)
- Spacer(minLength: 10)
- Text(self.subtitle)
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
- .padding(.top, self.paddingTop)
- .padding(.bottom, self.paddingBottom)
- .padding(.leading, self.paddingLeading)
- .padding(.trailing, self.paddingTrailing)
- .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
- .transaction { txn in txn.animation = nil }
+ MenuHeaderCard(
+ title: "Usage",
+ subtitle: self.subtitle)
}
private var subtitle: String {
diff --git a/apps/macos/Sources/OpenClaw/MicRefreshSupport.swift b/apps/macos/Sources/OpenClaw/MicRefreshSupport.swift
new file mode 100644
index 000000000000..3bf983cd3279
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/MicRefreshSupport.swift
@@ -0,0 +1,46 @@
+import Foundation
+import SwiftUI
+
+enum MicRefreshSupport {
+ private static let refreshDelayNs: UInt64 = 300_000_000
+
+ static func startObserver(_ observer: AudioInputDeviceObserver, triggerRefresh: @escaping @MainActor () -> Void) {
+ observer.start {
+ Task { @MainActor in
+ triggerRefresh()
+ }
+ }
+ }
+
+ @MainActor
+ static func schedule(
+ refreshTask: inout Task?,
+ action: @escaping @MainActor () async -> Void)
+ {
+ refreshTask?.cancel()
+ refreshTask = Task { @MainActor in
+ try? await Task.sleep(nanoseconds: self.refreshDelayNs)
+ guard !Task.isCancelled else { return }
+ await action()
+ }
+ }
+
+ static func selectedMicName(
+ selectedID: String,
+ in devices: [T],
+ uid: KeyPath,
+ name: KeyPath) -> String
+ {
+ guard !selectedID.isEmpty else { return "" }
+ return devices.first(where: { $0[keyPath: uid] == selectedID })?[keyPath: name] ?? ""
+ }
+
+ @MainActor
+ static func voiceWakeBinding(for state: AppState) -> Binding {
+ Binding(
+ get: { state.swabbleEnabled },
+ set: { newValue in
+ Task { await state.setVoiceWakeEnabled(newValue) }
+ })
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift
index bd4df512ca49..92e8d0cfb1a5 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeLocationService.swift
@@ -3,7 +3,7 @@ import Foundation
import OpenClawKit
@MainActor
-final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
+final class MacNodeLocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
enum Error: Swift.Error {
case timeout
case unavailable
@@ -12,21 +12,18 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var locationContinuation: CheckedContinuation?
- override init() {
- super.init()
- self.manager.delegate = self
- self.manager.desiredAccuracy = kCLLocationAccuracyBest
+ var locationManager: CLLocationManager {
+ self.manager
}
- func authorizationStatus() -> CLAuthorizationStatus {
- self.manager.authorizationStatus
+ var locationRequestContinuation: CheckedContinuation? {
+ get { self.locationContinuation }
+ set { self.locationContinuation = newValue }
}
- func accuracyAuthorization() -> CLAccuracyAuthorization {
- if #available(macOS 11.0, *) {
- return self.manager.accuracyAuthorization
- }
- return .fullAccuracy
+ override init() {
+ super.init()
+ self.configureLocationManager()
}
func currentLocation(
@@ -37,27 +34,17 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
guard CLLocationManager.locationServicesEnabled() else {
throw Error.unavailable
}
-
- let now = Date()
- if let maxAgeMs,
- let cached = self.manager.location,
- now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
- {
- return cached
- }
-
- self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
- let timeout = max(0, timeoutMs ?? 10000)
- return try await self.withTimeout(timeoutMs: timeout) {
- try await self.requestLocation()
- }
- }
-
- private func requestLocation() async throws -> CLLocation {
- try await withCheckedThrowingContinuation { cont in
- self.locationContinuation = cont
- self.manager.requestLocation()
- }
+ return try await LocationCurrentRequest.resolve(
+ manager: self.manager,
+ desiredAccuracy: desiredAccuracy,
+ maxAgeMs: maxAgeMs,
+ timeoutMs: timeoutMs,
+ request: { try await self.requestLocationOnce() },
+ withTimeout: { timeoutMs, operation in
+ try await self.withTimeout(timeoutMs: timeoutMs) {
+ try await operation()
+ }
+ })
}
private func withTimeout(
@@ -103,17 +90,6 @@ final class MacNodeLocationService: NSObject, CLLocationManagerDelegate {
}
}
- private static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
- switch accuracy {
- case .coarse:
- kCLLocationAccuracyKilometer
- case .balanced:
- kCLLocationAccuracyHundredMeters
- case .precise:
- kCLLocationAccuracyBest
- }
- }
-
// MARK: - CLLocationManagerDelegate (nonisolated for Swift 6 compatibility)
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
diff --git a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift
index 10598d7f4be9..bd27e49626b8 100644
--- a/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift
+++ b/apps/macos/Sources/OpenClaw/NodePairingApprovalPrompter.swift
@@ -32,9 +32,7 @@ final class NodePairingApprovalPrompter {
private var queue: [PendingRequest] = []
var pendingCount: Int = 0
var pendingRepairCount: Int = 0
- private var activeAlert: NSAlert?
- private var activeRequestId: String?
- private var alertHostWindow: NSWindow?
+ private let alertState = PairingAlertState()
private var remoteResolutionsByRequestId: [String: PairingResolution] = [:]
private var autoApproveAttempts: Set = []
@@ -68,55 +66,43 @@ final class NodePairingApprovalPrompter {
}
}
- private struct PairingResolvedEvent: Codable {
- let requestId: String
- let nodeId: String
- let decision: String
- let ts: Double
- }
-
- private enum PairingResolution: String {
- case approved
- case rejected
- }
+ private typealias PairingResolvedEvent = PairingAlertSupport.PairingResolvedEvent
+ private typealias PairingResolution = PairingAlertSupport.PairingResolution
func start() {
- guard self.task == nil else { return }
- self.isStopping = false
self.reconcileTask?.cancel()
self.reconcileTask = nil
- self.task = Task { [weak self] in
- guard let self else { return }
- _ = try? await GatewayConnection.shared.refresh()
- await self.loadPendingRequestsFromGateway()
- let stream = await GatewayConnection.shared.subscribe(bufferingNewest: 200)
- for await push in stream {
- if Task.isCancelled { return }
- await MainActor.run { [weak self] in self?.handle(push: push) }
- }
- }
+ self.startPushTask()
+ }
+
+ private func startPushTask() {
+ PairingAlertSupport.startPairingPushTask(
+ task: &self.task,
+ isStopping: &self.isStopping,
+ loadPending: self.loadPendingRequestsFromGateway,
+ handlePush: self.handle(push:))
}
func stop() {
- self.isStopping = true
- self.endActiveAlert()
- self.task?.cancel()
- self.task = nil
+ self.stopPushTask()
self.reconcileTask?.cancel()
self.reconcileTask = nil
self.reconcileOnceTask?.cancel()
self.reconcileOnceTask = nil
- self.queue.removeAll(keepingCapacity: false)
self.updatePendingCounts()
- self.isPresenting = false
- self.activeRequestId = nil
- self.alertHostWindow?.orderOut(nil)
- self.alertHostWindow?.close()
- self.alertHostWindow = nil
self.remoteResolutionsByRequestId.removeAll(keepingCapacity: false)
self.autoApproveAttempts.removeAll(keepingCapacity: false)
}
+ private func stopPushTask() {
+ PairingAlertSupport.stopPairingPrompter(
+ isStopping: &self.isStopping,
+ task: &self.task,
+ queue: &self.queue,
+ isPresenting: &self.isPresenting,
+ state: self.alertState)
+ }
+
private func loadPendingRequestsFromGateway() async {
// The gateway process may start slightly after the app. Retry a bit so
// pending pairing prompts are still shown on launch.
@@ -190,7 +176,7 @@ final class NodePairingApprovalPrompter {
if pendingById[req.requestId] != nil { continue }
let resolution = self.inferResolution(for: req, list: list)
- if self.activeRequestId == req.requestId, self.activeAlert != nil {
+ if self.alertState.activeRequestId == req.requestId, self.alertState.activeAlert != nil {
self.remoteResolutionsByRequestId[req.requestId] = resolution
self.logger.info(
"""
@@ -232,11 +218,7 @@ final class NodePairingApprovalPrompter {
}
private func endActiveAlert() {
- PairingAlertSupport.endActiveAlert(activeAlert: &self.activeAlert, activeRequestId: &self.activeRequestId)
- }
-
- private func requireAlertHostWindow() -> NSWindow {
- PairingAlertSupport.requireAlertHostWindow(alertHostWindow: &self.alertHostWindow)
+ PairingAlertSupport.endActiveAlert(state: self.alertState)
}
private func handle(push: GatewayPush) {
@@ -293,47 +275,13 @@ final class NodePairingApprovalPrompter {
private func presentAlert(for req: PendingRequest) {
self.logger.info("presenting node pairing alert requestId=\(req.requestId, privacy: .public)")
- NSApp.activate(ignoringOtherApps: true)
-
- let alert = NSAlert()
- alert.alertStyle = .warning
- alert.messageText = "Allow node to connect?"
- alert.informativeText = Self.describe(req)
- // Fail-safe ordering: if the dialog can't be presented, default to "Later".
- alert.addButton(withTitle: "Later")
- alert.addButton(withTitle: "Approve")
- alert.addButton(withTitle: "Reject")
- if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
- alert.buttons[2].hasDestructiveAction = true
- }
-
- self.activeAlert = alert
- self.activeRequestId = req.requestId
- let hostWindow = self.requireAlertHostWindow()
-
- // Position the hidden host window so the sheet appears centered on screen.
- // (Sheets attach to the top edge of their parent window; if the parent is tiny, it looks "anchored".)
- let sheetSize = alert.window.frame.size
- if let screen = hostWindow.screen ?? NSScreen.main {
- let bounds = screen.visibleFrame
- let x = bounds.midX - (sheetSize.width / 2)
- let sheetOriginY = bounds.midY - (sheetSize.height / 2)
- let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
- hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
- } else {
- hostWindow.center()
- }
-
- hostWindow.makeKeyAndOrderFront(nil)
- alert.beginSheetModal(for: hostWindow) { [weak self] response in
- Task { @MainActor [weak self] in
- guard let self else { return }
- self.activeRequestId = nil
- self.activeAlert = nil
- await self.handleAlertResponse(response, request: req)
- hostWindow.orderOut(nil)
- }
- }
+ PairingAlertSupport.presentPairingAlert(
+ request: req,
+ requestId: req.requestId,
+ messageText: "Allow node to connect?",
+ informativeText: Self.describe(req),
+ state: self.alertState,
+ onResponse: self.handleAlertResponse)
}
private func handleAlertResponse(_ response: NSApplication.ModalResponse, request: PendingRequest) async {
@@ -373,24 +321,22 @@ final class NodePairingApprovalPrompter {
}
private func approve(requestId: String) async -> Bool {
- do {
+ await PairingAlertSupport.approveRequest(
+ requestId: requestId,
+ kind: "node",
+ logger: self.logger)
+ {
try await GatewayConnection.shared.nodePairApprove(requestId: requestId)
- self.logger.info("approved node pairing requestId=\(requestId, privacy: .public)")
- return true
- } catch {
- self.logger.error("approve failed requestId=\(requestId, privacy: .public)")
- self.logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
- return false
}
}
private func reject(requestId: String) async {
- do {
+ await PairingAlertSupport.rejectRequest(
+ requestId: requestId,
+ kind: "node",
+ logger: self.logger)
+ {
try await GatewayConnection.shared.nodePairReject(requestId: requestId)
- self.logger.info("rejected node pairing requestId=\(requestId, privacy: .public)")
- } catch {
- self.logger.error("reject failed requestId=\(requestId, privacy: .public)")
- self.logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
}
}
@@ -419,8 +365,7 @@ final class NodePairingApprovalPrompter {
private static func prettyPlatform(_ platform: String?) -> String? {
let raw = platform?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let raw, !raw.isEmpty else { return nil }
- if raw.lowercased() == "ios" { return "iOS" }
- if raw.lowercased() == "macos" { return "macOS" }
+ if let pretty = PlatformLabelFormatter.pretty(raw) { return pretty }
return raw
}
@@ -616,7 +561,7 @@ final class NodePairingApprovalPrompter {
let resolution: PairingResolution =
resolved.decision == PairingResolution.approved.rawValue ? .approved : .rejected
- if self.activeRequestId == resolved.requestId, self.activeAlert != nil {
+ if self.alertState.activeRequestId == resolved.requestId, self.alertState.activeAlert != nil {
self.remoteResolutionsByRequestId[resolved.requestId] = resolution
self.logger.info(
"""
diff --git a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift
index 38d0aa30241b..7a9da5925f85 100644
--- a/apps/macos/Sources/OpenClaw/NodeServiceManager.swift
+++ b/apps/macos/Sources/OpenClaw/NodeServiceManager.swift
@@ -103,15 +103,9 @@ extension NodeServiceManager {
}
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard let start = trimmed.firstIndex(of: "{"),
- let end = trimmed.lastIndex(of: "}")
- else {
- return nil
- }
- let jsonText = String(trimmed[start...end])
- guard let data = jsonText.data(using: .utf8) else { return nil }
- guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
+ guard let parsed = JSONObjectExtractionSupport.extract(from: raw) else { return nil }
+ let jsonText = parsed.text
+ let object = parsed.object
let ok = object["ok"] as? Bool
let result = object["result"] as? String
let message = object["message"] as? String
@@ -139,12 +133,6 @@ extension NodeServiceManager {
}
private static func summarize(_ text: String) -> String? {
- let lines = text
- .split(whereSeparator: \.isNewline)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- guard let last = lines.last else { return nil }
- let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
- return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
+ TextSummarySupport.summarizeLastLine(text)
}
}
diff --git a/apps/macos/Sources/OpenClaw/NodesMenu.swift b/apps/macos/Sources/OpenClaw/NodesMenu.swift
index f88177d8dd02..c597b39de319 100644
--- a/apps/macos/Sources/OpenClaw/NodesMenu.swift
+++ b/apps/macos/Sources/OpenClaw/NodesMenu.swift
@@ -68,7 +68,7 @@ struct NodeMenuEntryFormatter {
static func platformText(_ entry: NodeInfo) -> String? {
if let raw = entry.platform?.nonEmpty {
- return self.prettyPlatform(raw) ?? raw
+ return PlatformLabelFormatter.pretty(raw) ?? raw
}
if let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") { return "macOS" }
@@ -79,34 +79,6 @@ struct NodeMenuEntryFormatter {
return nil
}
- private static func prettyPlatform(_ raw: String) -> String? {
- let (prefix, version) = self.parsePlatform(raw)
- if prefix.isEmpty { return nil }
- let name: String = switch prefix {
- case "macos": "macOS"
- case "ios": "iOS"
- case "ipados": "iPadOS"
- case "tvos": "tvOS"
- case "watchos": "watchOS"
- default: prefix.prefix(1).uppercased() + prefix.dropFirst()
- }
- guard let version, !version.isEmpty else { return name }
- let parts = version.split(separator: ".").map(String.init)
- if parts.count >= 2 {
- return "\(name) \(parts[0]).\(parts[1])"
- }
- return "\(name) \(version)"
- }
-
- private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty { return ("", nil) }
- let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
- let prefix = parts.first?.lowercased() ?? ""
- let versionToken = parts.dropFirst().first
- return (prefix, versionToken)
- }
-
private static func compactVersion(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
@@ -201,12 +173,8 @@ struct NodeMenuRowView: View {
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
- private var primaryColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
- }
-
- private var secondaryColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
+ private var palette: MenuItemHighlightColors.Palette {
+ MenuItemHighlightColors.palette(self.isHighlighted)
}
var body: some View {
@@ -218,7 +186,7 @@ struct NodeMenuRowView: View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.primaryName(self.entry))
.font(.callout.weight(NodeMenuEntryFormatter.isConnected(self.entry) ? .semibold : .regular))
- .foregroundStyle(self.primaryColor)
+ .foregroundStyle(self.palette.primary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
@@ -229,7 +197,7 @@ struct NodeMenuRowView: View {
if let right = NodeMenuEntryFormatter.headlineRight(self.entry) {
Text(right)
.font(.caption.monospacedDigit())
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(2)
@@ -237,7 +205,7 @@ struct NodeMenuRowView: View {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
.padding(.leading, 2)
}
}
@@ -245,7 +213,7 @@ struct NodeMenuRowView: View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
.font(.caption)
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
@@ -254,7 +222,7 @@ struct NodeMenuRowView: View {
if let version = NodeMenuEntryFormatter.detailRightVersion(self.entry) {
Text(version)
.font(.caption.monospacedDigit())
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
@@ -273,11 +241,11 @@ struct NodeMenuRowView: View {
private var leadingIcon: some View {
if NodeMenuEntryFormatter.isAndroid(self.entry) {
AndroidMark()
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
} else {
Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry))
.font(.system(size: 18, weight: .regular))
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
}
}
}
@@ -305,23 +273,19 @@ struct NodeMenuMultilineView: View {
let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted
- private var primaryColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
- }
-
- private var secondaryColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
+ private var palette: MenuItemHighlightColors.Palette {
+ MenuItemHighlightColors.palette(self.isHighlighted)
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(self.label):")
.font(.caption.weight(.semibold))
- .foregroundStyle(self.secondaryColor)
+ .foregroundStyle(self.palette.secondary)
Text(self.value)
.font(.caption)
- .foregroundStyle(self.primaryColor)
+ .foregroundStyle(self.palette.primary)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
diff --git a/apps/macos/Sources/OpenClaw/NodesStore.swift b/apps/macos/Sources/OpenClaw/NodesStore.swift
index 5cc94858645b..830c60689343 100644
--- a/apps/macos/Sources/OpenClaw/NodesStore.swift
+++ b/apps/macos/Sources/OpenClaw/NodesStore.swift
@@ -54,14 +54,8 @@ final class NodesStore {
func start() {
self.startCount += 1
guard self.startCount == 1 else { return }
- guard self.task == nil else { return }
- self.task = Task.detached { [weak self] in
- guard let self else { return }
- await self.refresh()
- while !Task.isCancelled {
- try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
- await self.refresh()
- }
+ SimpleTaskSupport.startDetachedLoop(task: &self.task, interval: self.interval) { [weak self] in
+ await self?.refresh()
}
}
diff --git a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift
index 31157b0d831b..d432f5a9a8ec 100644
--- a/apps/macos/Sources/OpenClaw/NotifyOverlay.swift
+++ b/apps/macos/Sources/OpenClaw/NotifyOverlay.swift
@@ -50,17 +50,8 @@ final class NotifyOverlayController {
self.dismissTask = nil
guard let window else { return }
- let target = window.frame.offsetBy(dx: 8, dy: 6)
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.16
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 0
- } completionHandler: {
- Task { @MainActor in
- window.orderOut(nil)
- self.model.isVisible = false
- }
+ OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 8, offsetY: 6) {
+ self.model.isVisible = false
}
}
@@ -70,21 +61,11 @@ final class NotifyOverlayController {
self.ensureWindow()
self.hostingView?.rootView = NotifyOverlayView(controller: self)
let target = self.targetFrame()
-
- guard let window else { return }
- if !self.model.isVisible {
- self.model.isVisible = true
- let start = target.offsetBy(dx: 0, dy: -6)
- window.setFrame(start, display: true)
- window.alphaValue = 0
- window.orderFrontRegardless()
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 1
- }
- } else {
+ OverlayPanelFactory.present(
+ window: self.window,
+ isVisible: &self.model.isVisible,
+ target: target)
+ { window in
self.updateWindowFrame(animate: true)
window.orderFrontRegardless()
}
@@ -92,22 +73,10 @@ final class NotifyOverlayController {
private func ensureWindow() {
if self.window != nil { return }
- let panel = NSPanel(
+ let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.minHeight),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false)
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.hasShadow = true
- panel.level = .statusBar
- panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
- panel.hidesOnDeactivate = false
- panel.isMovable = false
- panel.isFloatingPanel = true
- panel.becomesKeyOnlyIfNeeded = true
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
+ level: .statusBar,
+ hasShadow: true)
let host = NSHostingView(rootView: NotifyOverlayView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
@@ -126,17 +95,7 @@ final class NotifyOverlayController {
}
private func updateWindowFrame(animate: Bool = false) {
- guard let window else { return }
- let frame = self.targetFrame()
- if animate {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.12
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(frame, display: true)
- }
- } else {
- window.setFrame(frame, display: true)
- }
+ OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
private func measuredHeight() -> CGFloat {
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
index a521926ddb99..23b051cbc99d 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift
@@ -24,19 +24,7 @@ extension OnboardingView {
Task { await self.onboardingWizard.cancelIfRunning() }
self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
-
- if self.state.remoteTransport == .direct {
- self.state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
- } else {
- self.state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
- }
- if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
- OpenClawConfigFile.setRemoteGatewayUrl(
- host: endpoint.host,
- port: endpoint.port)
- } else {
- OpenClawConfigFile.clearRemoteGatewayUrl()
- }
+ GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state)
self.state.connectionMode = .remote
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
index 9b0e45e205c6..7ea549d9abb0 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift
@@ -189,19 +189,7 @@ extension OnboardingView {
}
func featureRow(title: String, subtitle: String, systemImage: String) -> some View {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: systemImage)
- .font(.title3.weight(.semibold))
- .foregroundStyle(Color.accentColor)
- .frame(width: 26)
- VStack(alignment: .leading, spacing: 4) {
- Text(title).font(.headline)
- Text(subtitle)
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- }
- .padding(.vertical, 4)
+ self.featureRowContent(title: title, subtitle: subtitle, systemImage: systemImage)
}
func featureActionRow(
@@ -210,6 +198,22 @@ extension OnboardingView {
systemImage: String,
buttonTitle: String,
action: @escaping () -> Void) -> some View
+ {
+ self.featureRowContent(
+ title: title,
+ subtitle: subtitle,
+ systemImage: systemImage,
+ action: AnyView(
+ Button(buttonTitle, action: action)
+ .buttonStyle(.link)
+ .padding(.top, 2)))
+ }
+
+ private func featureRowContent(
+ title: String,
+ subtitle: String,
+ systemImage: String,
+ action: AnyView? = nil) -> some View
{
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
@@ -221,9 +225,9 @@ extension OnboardingView {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
- Button(buttonTitle, action: action)
- .buttonStyle(.link)
- .padding(.top, 2)
+ if let action {
+ action
+ }
}
Spacer(minLength: 0)
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
index efe37f31673c..e7150edc55b8 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift
@@ -17,14 +17,9 @@ extension OnboardingView {
}
func updatePermissionMonitoring(for pageIndex: Int) {
- let shouldMonitor = pageIndex == self.permissionsPageIndex
- if shouldMonitor, !self.monitoringPermissions {
- self.monitoringPermissions = true
- PermissionMonitor.shared.register()
- } else if !shouldMonitor, self.monitoringPermissions {
- self.monitoringPermissions = false
- PermissionMonitor.shared.unregister()
- }
+ PermissionMonitoringSupport.setMonitoring(
+ pageIndex == self.permissionsPageIndex,
+ monitoring: &self.monitoringPermissions)
}
func updateDiscoveryMonitoring(for pageIndex: Int) {
@@ -51,9 +46,7 @@ extension OnboardingView {
}
func stopPermissionMonitoring() {
- guard self.monitoringPermissions else { return }
- self.monitoringPermissions = false
- PermissionMonitor.shared.unregister()
+ PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions)
}
func stopDiscovery() {
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
index 4f942dfe8a4f..e8e3ee772cab 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift
@@ -315,25 +315,9 @@ extension OnboardingView {
}
}
Spacer(minLength: 0)
- if selected {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(Color.accentColor)
- } else {
- Image(systemName: "arrow.right.circle")
- .foregroundStyle(.secondary)
- }
+ SelectionStateIndicator(selected: selected)
}
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(selected ? Color.accentColor.opacity(0.12) : Color.clear))
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .strokeBorder(
- selected ? Color.accentColor.opacity(0.45) : Color.clear,
- lineWidth: 1))
+ .openClawSelectableRowChrome(selected: selected)
}
.buttonStyle(.plain)
}
diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift
index 7538f846b890..87a30e3285f0 100644
--- a/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift
+++ b/apps/macos/Sources/OpenClaw/OnboardingView+Workspace.swift
@@ -69,9 +69,7 @@ extension OnboardingView {
private func loadAgentWorkspace() async -> String? {
let root = await ConfigStore.load()
- let agents = root["agents"] as? [String: Any]
- let defaults = agents?["defaults"] as? [String: Any]
- return defaults?["workspace"] as? String
+ return AgentWorkspaceConfig.workspace(from: root)
}
@discardableResult
@@ -87,24 +85,7 @@ extension OnboardingView {
@MainActor
private static func buildAndSaveWorkspace(_ workspace: String?) async -> (Bool, String?) {
var root = await ConfigStore.load()
- var agents = root["agents"] as? [String: Any] ?? [:]
- var defaults = agents["defaults"] as? [String: Any] ?? [:]
- let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmed.isEmpty {
- defaults.removeValue(forKey: "workspace")
- } else {
- defaults["workspace"] = trimmed
- }
- if defaults.isEmpty {
- agents.removeValue(forKey: "defaults")
- } else {
- agents["defaults"] = defaults
- }
- if agents.isEmpty {
- root.removeValue(forKey: "agents")
- } else {
- root["agents"] = agents
- }
+ AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
do {
try await ConfigStore.save(root)
return (true, nil)
diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
index 35744baeda52..b112adc28509 100644
--- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
+++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
@@ -127,34 +127,15 @@ enum OpenClawConfigFile {
}
static func agentWorkspace() -> String? {
- let root = self.loadDict()
- let agents = root["agents"] as? [String: Any]
- let defaults = agents?["defaults"] as? [String: Any]
- return defaults?["workspace"] as? String
+ AgentWorkspaceConfig.workspace(from: self.loadDict())
}
static func setAgentWorkspace(_ workspace: String?) {
var root = self.loadDict()
- var agents = root["agents"] as? [String: Any] ?? [:]
- var defaults = agents["defaults"] as? [String: Any] ?? [:]
- let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmed.isEmpty {
- defaults.removeValue(forKey: "workspace")
- } else {
- defaults["workspace"] = trimmed
- }
- if defaults.isEmpty {
- agents.removeValue(forKey: "defaults")
- } else {
- agents["defaults"] = defaults
- }
- if agents.isEmpty {
- root.removeValue(forKey: "agents")
- } else {
- root["agents"] = agents
- }
+ AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
self.saveDict(root)
- self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)")
+ let hasWorkspace = !(workspace?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
+ self.logger.debug("agents.defaults.workspace updated set=\(hasWorkspace)")
}
static func gatewayPassword() -> String? {
@@ -249,7 +230,7 @@ enum OpenClawConfigFile {
return url
}
- private static func hostKey(_ host: String) -> String {
+ static func hostKey(_ host: String) -> String {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return "" }
if trimmed.contains(":") { return trimmed }
diff --git a/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift
new file mode 100644
index 000000000000..b1d6570d81fd
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/OverlayPanelFactory.swift
@@ -0,0 +1,126 @@
+import AppKit
+import QuartzCore
+
+enum OverlayPanelFactory {
+ @MainActor
+ static func makePanel(
+ contentRect: NSRect,
+ level: NSWindow.Level,
+ hasShadow: Bool,
+ acceptsMouseMovedEvents: Bool = false) -> NSPanel
+ {
+ let panel = NSPanel(
+ contentRect: contentRect,
+ styleMask: [.nonactivatingPanel, .borderless],
+ backing: .buffered,
+ defer: false)
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = hasShadow
+ panel.level = level
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
+ panel.hidesOnDeactivate = false
+ panel.isMovable = false
+ panel.isFloatingPanel = true
+ panel.becomesKeyOnlyIfNeeded = true
+ panel.titleVisibility = .hidden
+ panel.titlebarAppearsTransparent = true
+ panel.acceptsMouseMovedEvents = acceptsMouseMovedEvents
+ return panel
+ }
+
+ @MainActor
+ static func animatePresent(window: NSWindow, from start: NSRect, to target: NSRect, duration: TimeInterval = 0.18) {
+ window.setFrame(start, display: true)
+ window.alphaValue = 0
+ window.orderFrontRegardless()
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = duration
+ context.timingFunction = CAMediaTimingFunction(name: .easeOut)
+ window.animator().setFrame(target, display: true)
+ window.animator().alphaValue = 1
+ }
+ }
+
+ @MainActor
+ static func animateFrame(window: NSWindow, to frame: NSRect, duration: TimeInterval = 0.12) {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = duration
+ context.timingFunction = CAMediaTimingFunction(name: .easeOut)
+ window.animator().setFrame(frame, display: true)
+ }
+ }
+
+ @MainActor
+ static func applyFrame(window: NSWindow?, target: NSRect, animate: Bool) {
+ guard let window else { return }
+ if animate {
+ self.animateFrame(window: window, to: target)
+ } else {
+ window.setFrame(target, display: true)
+ }
+ }
+
+ @MainActor
+ static func present(
+ window: NSWindow?,
+ isVisible: inout Bool,
+ target: NSRect,
+ startOffsetY: CGFloat = -6,
+ onFirstPresent: (() -> Void)? = nil,
+ onAlreadyVisible: (NSWindow) -> Void)
+ {
+ guard let window else { return }
+ if !isVisible {
+ isVisible = true
+ onFirstPresent?()
+ let start = target.offsetBy(dx: 0, dy: startOffsetY)
+ self.animatePresent(window: window, from: start, to: target)
+ } else {
+ onAlreadyVisible(window)
+ }
+ }
+
+ @MainActor
+ static func animateDismiss(
+ window: NSWindow,
+ offsetX: CGFloat = 6,
+ offsetY: CGFloat = 6,
+ duration: TimeInterval = 0.16,
+ completion: @escaping () -> Void)
+ {
+ let target = window.frame.offsetBy(dx: offsetX, dy: offsetY)
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = duration
+ context.timingFunction = CAMediaTimingFunction(name: .easeOut)
+ window.animator().setFrame(target, display: true)
+ window.animator().alphaValue = 0
+ } completionHandler: {
+ completion()
+ }
+ }
+
+ @MainActor
+ static func animateDismissAndHide(
+ window: NSWindow,
+ offsetX: CGFloat = 6,
+ offsetY: CGFloat = 6,
+ duration: TimeInterval = 0.16,
+ onHidden: @escaping @MainActor () -> Void)
+ {
+ self.animateDismiss(window: window, offsetX: offsetX, offsetY: offsetY, duration: duration) {
+ Task { @MainActor in
+ window.orderOut(nil)
+ onHidden()
+ }
+ }
+ }
+
+ @MainActor
+ static func clearGlobalEventMonitor(_ monitor: inout Any?) {
+ if let current = monitor {
+ NSEvent.removeMonitor(current)
+ monitor = nil
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift
index e8e4428bf3fd..e806510c03a2 100644
--- a/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift
+++ b/apps/macos/Sources/OpenClaw/PairingAlertSupport.swift
@@ -1,4 +1,6 @@
import AppKit
+import OpenClawKit
+import OSLog
final class PairingAlertHostWindow: NSWindow {
override var canBecomeKey: Bool {
@@ -10,8 +12,26 @@ final class PairingAlertHostWindow: NSWindow {
}
}
+@MainActor
+final class PairingAlertState {
+ var activeAlert: NSAlert?
+ var activeRequestId: String?
+ var alertHostWindow: NSWindow?
+}
+
@MainActor
enum PairingAlertSupport {
+ enum PairingResolution: String {
+ case approved
+ case rejected
+ }
+
+ struct PairingResolvedEvent: Codable {
+ let requestId: String
+ let decision: String
+ let ts: Double
+ }
+
static func endActiveAlert(activeAlert: inout NSAlert?, activeRequestId: inout String?) {
guard let alert = activeAlert else { return }
if let parent = alert.window.sheetParent {
@@ -21,6 +41,10 @@ enum PairingAlertSupport {
activeRequestId = nil
}
+ static func endActiveAlert(state: PairingAlertState) {
+ self.endActiveAlert(activeAlert: &state.activeAlert, activeRequestId: &state.activeRequestId)
+ }
+
static func requireAlertHostWindow(alertHostWindow: inout NSWindow?) -> NSWindow {
if let alertHostWindow {
return alertHostWindow
@@ -43,4 +67,211 @@ enum PairingAlertSupport {
alertHostWindow = window
return window
}
+
+ static func configureDefaultPairingAlert(
+ _ alert: NSAlert,
+ messageText: String,
+ informativeText: String)
+ {
+ alert.alertStyle = .warning
+ alert.messageText = messageText
+ alert.informativeText = informativeText
+ alert.addButton(withTitle: "Later")
+ alert.addButton(withTitle: "Approve")
+ alert.addButton(withTitle: "Reject")
+ if #available(macOS 11.0, *), alert.buttons.indices.contains(2) {
+ alert.buttons[2].hasDestructiveAction = true
+ }
+ }
+
+ static func beginCenteredSheet(
+ alert: NSAlert,
+ hostWindow: NSWindow,
+ completionHandler: @escaping (NSApplication.ModalResponse) -> Void)
+ {
+ let sheetSize = alert.window.frame.size
+ if let screen = hostWindow.screen ?? NSScreen.main {
+ let bounds = screen.visibleFrame
+ let x = bounds.midX - (sheetSize.width / 2)
+ let sheetOriginY = bounds.midY - (sheetSize.height / 2)
+ let hostY = sheetOriginY + sheetSize.height - hostWindow.frame.height
+ hostWindow.setFrameOrigin(NSPoint(x: x, y: hostY))
+ } else {
+ hostWindow.center()
+ }
+ hostWindow.makeKeyAndOrderFront(nil)
+ alert.beginSheetModal(for: hostWindow, completionHandler: completionHandler)
+ }
+
+ static func runPairingPushTask(
+ bufferingNewest: Int = 200,
+ loadPending: @escaping @MainActor () async -> Void,
+ handlePush: @escaping @MainActor (GatewayPush) -> Void) async
+ {
+ _ = try? await GatewayConnection.shared.refresh()
+ await loadPending()
+ await GatewayPushSubscription.consume(bufferingNewest: bufferingNewest, onPush: handlePush)
+ }
+
+ static func startPairingPushTask(
+ task: inout Task?,
+ isStopping: inout Bool,
+ bufferingNewest: Int = 200,
+ loadPending: @escaping @MainActor () async -> Void,
+ handlePush: @escaping @MainActor (GatewayPush) -> Void)
+ {
+ guard task == nil else { return }
+ isStopping = false
+ task = Task {
+ await self.runPairingPushTask(
+ bufferingNewest: bufferingNewest,
+ loadPending: loadPending,
+ handlePush: handlePush)
+ }
+ }
+
+ static func beginPairingAlert(
+ messageText: String,
+ informativeText: String,
+ alertHostWindow: inout NSWindow?,
+ completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void) -> NSAlert
+ {
+ NSApp.activate(ignoringOtherApps: true)
+
+ let alert = NSAlert()
+ self.configureDefaultPairingAlert(alert, messageText: messageText, informativeText: informativeText)
+
+ let hostWindow = self.requireAlertHostWindow(alertHostWindow: &alertHostWindow)
+ self.beginCenteredSheet(alert: alert, hostWindow: hostWindow) { response in
+ completion(response, hostWindow)
+ }
+ return alert
+ }
+
+ static func presentPairingAlert(
+ requestId: String,
+ messageText: String,
+ informativeText: String,
+ activeAlert: inout NSAlert?,
+ activeRequestId: inout String?,
+ alertHostWindow: inout NSWindow?,
+ completion: @escaping (NSApplication.ModalResponse, NSWindow) -> Void)
+ {
+ activeRequestId = requestId
+ activeAlert = self.beginPairingAlert(
+ messageText: messageText,
+ informativeText: informativeText,
+ alertHostWindow: &alertHostWindow,
+ completion: completion)
+ }
+
+ static func presentPairingAlert(
+ request: Request,
+ requestId: String,
+ messageText: String,
+ informativeText: String,
+ state: PairingAlertState,
+ onResponse: @escaping @MainActor (NSApplication.ModalResponse, Request) async -> Void)
+ {
+ self.presentPairingAlert(
+ requestId: requestId,
+ messageText: messageText,
+ informativeText: informativeText,
+ activeAlert: &state.activeAlert,
+ activeRequestId: &state.activeRequestId,
+ alertHostWindow: &state.alertHostWindow,
+ completion: { response, hostWindow in
+ Task { @MainActor in
+ self.clearActivePairingAlert(state: state, hostWindow: hostWindow)
+ await onResponse(response, request)
+ }
+ })
+ }
+
+ static func clearActivePairingAlert(
+ activeAlert: inout NSAlert?,
+ activeRequestId: inout String?,
+ hostWindow: NSWindow)
+ {
+ activeRequestId = nil
+ activeAlert = nil
+ hostWindow.orderOut(nil)
+ }
+
+ static func clearActivePairingAlert(state: PairingAlertState, hostWindow: NSWindow) {
+ self.clearActivePairingAlert(
+ activeAlert: &state.activeAlert,
+ activeRequestId: &state.activeRequestId,
+ hostWindow: hostWindow)
+ }
+
+ static func stopPairingPrompter(
+ isStopping: inout Bool,
+ activeAlert: inout NSAlert?,
+ activeRequestId: inout String?,
+ task: inout Task?,
+ queue: inout [some Any],
+ isPresenting: inout Bool,
+ alertHostWindow: inout NSWindow?)
+ {
+ isStopping = true
+ self.endActiveAlert(activeAlert: &activeAlert, activeRequestId: &activeRequestId)
+ task?.cancel()
+ task = nil
+ queue.removeAll(keepingCapacity: false)
+ isPresenting = false
+ activeRequestId = nil
+ alertHostWindow?.orderOut(nil)
+ alertHostWindow?.close()
+ alertHostWindow = nil
+ }
+
+ static func stopPairingPrompter(
+ isStopping: inout Bool,
+ task: inout Task?,
+ queue: inout [some Any],
+ isPresenting: inout Bool,
+ state: PairingAlertState)
+ {
+ self.stopPairingPrompter(
+ isStopping: &isStopping,
+ activeAlert: &state.activeAlert,
+ activeRequestId: &state.activeRequestId,
+ task: &task,
+ queue: &queue,
+ isPresenting: &isPresenting,
+ alertHostWindow: &state.alertHostWindow)
+ }
+
+ static func approveRequest(
+ requestId: String,
+ kind: String,
+ logger: Logger,
+ action: @escaping () async throws -> Void) async -> Bool
+ {
+ do {
+ try await action()
+ logger.info("approved \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
+ return true
+ } catch {
+ logger.error("approve failed requestId=\(requestId, privacy: .public)")
+ logger.error("approve failed: \(error.localizedDescription, privacy: .public)")
+ return false
+ }
+ }
+
+ static func rejectRequest(
+ requestId: String,
+ kind: String,
+ logger: Logger,
+ action: @escaping () async throws -> Void) async
+ {
+ do {
+ try await action()
+ logger.info("rejected \(kind, privacy: .public) pairing requestId=\(requestId, privacy: .public)")
+ } catch {
+ logger.error("reject failed requestId=\(requestId, privacy: .public)")
+ logger.error("reject failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
}
diff --git a/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift
index 9f97650b9f2c..07928e509439 100644
--- a/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift
+++ b/apps/macos/Sources/OpenClaw/PeekabooBridgeHostCoordinator.swift
@@ -13,12 +13,28 @@ final class PeekabooBridgeHostCoordinator {
private var host: PeekabooBridgeHost?
private var services: OpenClawPeekabooBridgeServices?
+
+ private static let legacySocketDirectoryNames = ["clawdbot", "clawdis", "moltbot"]
+
private static var openclawSocketPath: String {
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
- let directory = base.appendingPathComponent("OpenClaw", isDirectory: true)
- return directory.appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false).path
+ return Self.makeSocketPath(for: "OpenClaw", in: base)
+ }
+
+ private static func makeSocketPath(for directoryName: String, in baseDirectory: URL) -> String {
+ baseDirectory
+ .appendingPathComponent(directoryName, isDirectory: true)
+ .appendingPathComponent(PeekabooBridgeConstants.socketName, isDirectory: false)
+ .path
+ }
+
+ private static var legacySocketPaths: [String] {
+ let fileManager = FileManager.default
+ let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
+ return Self.legacySocketDirectoryNames.map { Self.makeSocketPath(for: $0, in: base) }
}
func setEnabled(_ enabled: Bool) async {
@@ -46,6 +62,8 @@ final class PeekabooBridgeHostCoordinator {
}
let allowlistedBundles: Set = []
+ self.ensureLegacySocketSymlinks()
+
let services = OpenClawPeekabooBridgeServices()
let server = PeekabooBridgeServer(
services: services,
@@ -67,6 +85,44 @@ final class PeekabooBridgeHostCoordinator {
.info("PeekabooBridge host started at \(Self.openclawSocketPath, privacy: .public)")
}
+ private func ensureLegacySocketSymlinks() {
+ for legacyPath in Self.legacySocketPaths {
+ self.ensureLegacySocketSymlink(at: legacyPath)
+ }
+ }
+
+ private func ensureLegacySocketSymlink(at legacyPath: String) {
+ let fileManager = FileManager.default
+ let legacyDirectory = (legacyPath as NSString).deletingLastPathComponent
+ do {
+ let directoryAttributes: [FileAttributeKey: Any] = [
+ .posixPermissions: 0o700,
+ ]
+ try fileManager.createDirectory(
+ atPath: legacyDirectory,
+ withIntermediateDirectories: true,
+ attributes: directoryAttributes)
+ let linkURL = URL(fileURLWithPath: legacyPath)
+ let linkValues = try? linkURL.resourceValues(forKeys: [.isSymbolicLinkKey])
+ if linkValues?.isSymbolicLink == true {
+ let destination = try FileManager.default.destinationOfSymbolicLink(atPath: legacyPath)
+ let destinationURL = URL(fileURLWithPath: destination, relativeTo: linkURL.deletingLastPathComponent())
+ .standardizedFileURL
+ if destinationURL.path == URL(fileURLWithPath: Self.openclawSocketPath).standardizedFileURL.path {
+ return
+ }
+ try fileManager.removeItem(atPath: legacyPath)
+ } else if fileManager.fileExists(atPath: legacyPath) {
+ try fileManager.removeItem(atPath: legacyPath)
+ }
+ try fileManager.createSymbolicLink(atPath: legacyPath, withDestinationPath: Self.openclawSocketPath)
+ } catch {
+ let message = "Failed to create legacy PeekabooBridge socket symlink: \(error.localizedDescription)"
+ self.logger
+ .debug("\(message, privacy: .public)")
+ }
+ }
+
private static func currentTeamID() -> String? {
var code: SecCode?
guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess,
diff --git a/apps/macos/Sources/OpenClaw/PermissionManager.swift b/apps/macos/Sources/OpenClaw/PermissionManager.swift
index b5bcd167a464..1d4901063763 100644
--- a/apps/macos/Sources/OpenClaw/PermissionManager.swift
+++ b/apps/macos/Sources/OpenClaw/PermissionManager.swift
@@ -229,61 +229,37 @@ enum PermissionManager {
enum NotificationPermissionHelper {
static func openSettings() {
- let candidates = [
+ SystemSettingsURLSupport.openFirst([
"x-apple.systempreferences:com.apple.Notifications-Settings.extension",
"x-apple.systempreferences:com.apple.preference.notifications",
- ]
-
- for candidate in candidates {
- if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
- return
- }
- }
+ ])
}
}
enum MicrophonePermissionHelper {
static func openSettings() {
- let candidates = [
+ SystemSettingsURLSupport.openFirst([
"x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone",
"x-apple.systempreferences:com.apple.preference.security",
- ]
-
- for candidate in candidates {
- if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
- return
- }
- }
+ ])
}
}
enum CameraPermissionHelper {
static func openSettings() {
- let candidates = [
+ SystemSettingsURLSupport.openFirst([
"x-apple.systempreferences:com.apple.preference.security?Privacy_Camera",
"x-apple.systempreferences:com.apple.preference.security",
- ]
-
- for candidate in candidates {
- if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
- return
- }
- }
+ ])
}
}
enum LocationPermissionHelper {
static func openSettings() {
- let candidates = [
+ SystemSettingsURLSupport.openFirst([
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices",
"x-apple.systempreferences:com.apple.preference.security",
- ]
-
- for candidate in candidates {
- if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
- return
- }
- }
+ ])
}
}
diff --git a/apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift b/apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift
new file mode 100644
index 000000000000..9d88ad5459d5
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/PermissionMonitoringSupport.swift
@@ -0,0 +1,20 @@
+import Foundation
+
+@MainActor
+enum PermissionMonitoringSupport {
+ static func setMonitoring(_ shouldMonitor: Bool, monitoring: inout Bool) {
+ if shouldMonitor, !monitoring {
+ monitoring = true
+ PermissionMonitor.shared.register()
+ } else if !shouldMonitor, monitoring {
+ monitoring = false
+ PermissionMonitor.shared.unregister()
+ }
+ }
+
+ static func stopMonitoring(_ monitoring: inout Bool) {
+ guard monitoring else { return }
+ monitoring = false
+ PermissionMonitor.shared.unregister()
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift b/apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift
new file mode 100644
index 000000000000..9fe170b1ddd3
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/PlatformLabelFormatter.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+enum PlatformLabelFormatter {
+ static func parse(_ raw: String) -> (prefix: String, version: String?) {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.isEmpty { return ("", nil) }
+ let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
+ let prefix = parts.first?.lowercased() ?? ""
+ let versionToken = parts.dropFirst().first
+ return (prefix, versionToken)
+ }
+
+ static func pretty(_ raw: String) -> String? {
+ let (prefix, version) = self.parse(raw)
+ if prefix.isEmpty { return nil }
+ let name: String = switch prefix {
+ case "macos": "macOS"
+ case "ios": "iOS"
+ case "ipados": "iPadOS"
+ case "tvos": "tvOS"
+ case "watchos": "watchOS"
+ default: prefix.prefix(1).uppercased() + prefix.dropFirst()
+ }
+ guard let version, !version.isEmpty else { return name }
+ let parts = version.split(separator: ".").map(String.init)
+ if parts.count >= 2 {
+ return "\(name) \(parts[0]).\(parts[1])"
+ }
+ return "\(name) \(version)"
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift
index 6502d2ad9160..82adc209c162 100644
--- a/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift
+++ b/apps/macos/Sources/OpenClaw/RemotePortTunnel.swift
@@ -152,8 +152,8 @@ final class RemotePortTunnel {
else {
return nil
}
- let sshKey = Self.hostKey(sshHost)
- let urlKey = Self.hostKey(host)
+ let sshKey = OpenClawConfigFile.hostKey(sshHost)
+ let urlKey = OpenClawConfigFile.hostKey(host)
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
guard sshKey == urlKey else {
Self.logger.debug(
@@ -163,17 +163,6 @@ final class RemotePortTunnel {
return port
}
- private static func hostKey(_ host: String) -> String {
- let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard !trimmed.isEmpty else { return "" }
- if trimmed.contains(":") { return trimmed }
- let digits = CharacterSet(charactersIn: "0123456789.")
- if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
- return trimmed
- }
- return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
- }
-
private static func findPort(preferred: UInt16?, allowRandom: Bool) async throws -> UInt16 {
if let preferred, self.portIsFree(preferred) { return preferred }
if let preferred, !allowRandom {
diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist
index 5601d6aed7aa..8ca28de8bd60 100644
--- a/apps/macos/Sources/OpenClaw/Resources/Info.plist
+++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist
@@ -15,7 +15,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.3.1
+ 2026.3.2
CFBundleVersion
202603010
CFBundleIconFile
diff --git a/apps/macos/Sources/OpenClaw/ScreenRecordService.swift b/apps/macos/Sources/OpenClaw/ScreenRecordService.swift
index 30d854b11478..a83eea9ebb3b 100644
--- a/apps/macos/Sources/OpenClaw/ScreenRecordService.swift
+++ b/apps/macos/Sources/OpenClaw/ScreenRecordService.swift
@@ -1,5 +1,6 @@
import AVFoundation
import Foundation
+import OpenClawKit
import OSLog
@preconcurrency import ScreenCaptureKit
@@ -34,8 +35,8 @@ final class ScreenRecordService {
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
- let durationMs = Self.clampDurationMs(durationMs)
- let fps = Self.clampFps(fps)
+ let durationMs = CaptureRateLimits.clampDurationMs(durationMs)
+ let fps = CaptureRateLimits.clampFps(fps, maxFps: 60)
let includeAudio = includeAudio ?? false
let outURL: URL = {
@@ -96,17 +97,6 @@ final class ScreenRecordService {
try await recorder.finish()
return (path: outURL.path, hasAudio: recorder.hasAudio)
}
-
- private nonisolated static func clampDurationMs(_ ms: Int?) -> Int {
- let v = ms ?? 10000
- return min(60000, max(250, v))
- }
-
- private nonisolated static func clampFps(_ fps: Double?) -> Double {
- let v = fps ?? 10
- if !v.isFinite { return 10 }
- return min(60, max(1, v))
- }
}
private final class StreamRecorder: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
diff --git a/apps/macos/Sources/OpenClaw/SelectableRow.swift b/apps/macos/Sources/OpenClaw/SelectableRow.swift
new file mode 100644
index 000000000000..e37a741aa080
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SelectableRow.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct SelectionStateIndicator: View {
+ let selected: Bool
+
+ var body: some View {
+ Group {
+ if self.selected {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(Color.accentColor)
+ } else {
+ Image(systemName: "arrow.right.circle")
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+}
+
+extension View {
+ func openClawSelectableRowChrome(selected: Bool, hovered: Bool = false) -> some View {
+ self
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(self.openClawRowBackground(selected: selected, hovered: hovered)))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .strokeBorder(
+ selected ? Color.accentColor.opacity(0.45) : Color.clear,
+ lineWidth: 1))
+ }
+
+ private func openClawRowBackground(selected: Bool, hovered: Bool) -> Color {
+ if selected { return Color.accentColor.opacity(0.12) }
+ if hovered { return Color.secondary.opacity(0.08) }
+ return Color.clear
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift
index 51646e0a36a3..a1a14dcce660 100644
--- a/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift
+++ b/apps/macos/Sources/OpenClaw/SessionMenuLabelView.swift
@@ -12,14 +12,6 @@ struct SessionMenuLabelView: View {
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
- private var primaryTextColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
- }
-
- private var secondaryTextColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
- }
-
var body: some View {
VStack(alignment: .leading, spacing: 8) {
ContextUsageBar(
@@ -31,7 +23,7 @@ struct SessionMenuLabelView: View {
HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(self.row.label)
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
- .foregroundStyle(self.primaryTextColor)
+ .foregroundStyle(MenuItemHighlightColors.primary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
@@ -40,14 +32,14 @@ struct SessionMenuLabelView: View {
Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)")
.font(.caption.monospacedDigit())
- .foregroundStyle(self.secondaryTextColor)
+ .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
- .foregroundStyle(self.secondaryTextColor)
+ .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
.padding(.leading, 2)
}
}
diff --git a/apps/macos/Sources/OpenClaw/SessionsSettings.swift b/apps/macos/Sources/OpenClaw/SessionsSettings.swift
index 826f1128f54d..766b23378046 100644
--- a/apps/macos/Sources/OpenClaw/SessionsSettings.swift
+++ b/apps/macos/Sources/OpenClaw/SessionsSettings.swift
@@ -44,16 +44,8 @@ struct SessionsSettings: View {
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
- if self.loading {
- ProgressView()
- } else {
- Button {
- Task { await self.refresh() }
- } label: {
- Label("Refresh", systemImage: "arrow.clockwise")
- }
- .buttonStyle(.bordered)
- .help("Refresh")
+ SettingsRefreshButton(isLoading: self.loading) {
+ Task { await self.refresh() }
}
}
}
diff --git a/apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift b/apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift
new file mode 100644
index 000000000000..c918919486cd
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SettingsRefreshButton.swift
@@ -0,0 +1,18 @@
+import SwiftUI
+
+struct SettingsRefreshButton: View {
+ let isLoading: Bool
+ let action: () -> Void
+
+ var body: some View {
+ if self.isLoading {
+ ProgressView()
+ } else {
+ Button(action: self.action) {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.bordered)
+ .help("Refresh")
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/apps/macos/Sources/OpenClaw/SettingsRootView.swift
index 016e2f3d1c7d..1c021aaa2dca 100644
--- a/apps/macos/Sources/OpenClaw/SettingsRootView.swift
+++ b/apps/macos/Sources/OpenClaw/SettingsRootView.swift
@@ -158,20 +158,11 @@ struct SettingsRootView: View {
private func updatePermissionMonitoring(for tab: SettingsTab) {
guard !self.isPreview else { return }
- let shouldMonitor = tab == .permissions
- if shouldMonitor, !self.monitoringPermissions {
- self.monitoringPermissions = true
- PermissionMonitor.shared.register()
- } else if !shouldMonitor, self.monitoringPermissions {
- self.monitoringPermissions = false
- PermissionMonitor.shared.unregister()
- }
+ PermissionMonitoringSupport.setMonitoring(tab == .permissions, monitoring: &self.monitoringPermissions)
}
private func stopPermissionMonitoring() {
- guard self.monitoringPermissions else { return }
- self.monitoringPermissions = false
- PermissionMonitor.shared.unregister()
+ PermissionMonitoringSupport.stopMonitoring(&self.monitoringPermissions)
}
}
diff --git a/apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift b/apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift
new file mode 100644
index 000000000000..b082d93b0ff3
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SettingsSidebarCard.swift
@@ -0,0 +1,12 @@
+import SwiftUI
+
+extension View {
+ func settingsSidebarCardLayout() -> some View {
+ self
+ .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color(nsColor: .windowBackgroundColor)))
+ .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift b/apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift
new file mode 100644
index 000000000000..5ac4f9bfe417
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SettingsSidebarScroll.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+struct SettingsSidebarScroll: View {
+ @ViewBuilder var content: Content
+
+ var body: some View {
+ ScrollView {
+ self.content
+ .padding(.vertical, 10)
+ .padding(.horizontal, 10)
+ }
+ .settingsSidebarCardLayout()
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift b/apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift
new file mode 100644
index 000000000000..6af7ea7de214
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SimpleFileWatcher.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+final class SimpleFileWatcher: @unchecked Sendable {
+ private let watcher: CoalescingFSEventsWatcher
+
+ init(_ watcher: CoalescingFSEventsWatcher) {
+ self.watcher = watcher
+ }
+
+ deinit {
+ self.stop()
+ }
+
+ func start() {
+ self.watcher.start()
+ }
+
+ func stop() {
+ self.watcher.stop()
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift b/apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift
new file mode 100644
index 000000000000..acbf58f2b23b
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SimpleFileWatcherOwner.swift
@@ -0,0 +1,15 @@
+import Foundation
+
+protocol SimpleFileWatcherOwner: AnyObject {
+ var watcher: SimpleFileWatcher { get }
+}
+
+extension SimpleFileWatcherOwner {
+ func start() {
+ self.watcher.start()
+ }
+
+ func stop() {
+ self.watcher.stop()
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift b/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift
new file mode 100644
index 000000000000..016b6ae75208
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SimpleTaskSupport.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+@MainActor
+enum SimpleTaskSupport {
+ static func start(task: inout Task?, operation: @escaping @Sendable () async -> Void) {
+ guard task == nil else { return }
+ task = Task {
+ await operation()
+ }
+ }
+
+ static func stop(task: inout Task?) {
+ task?.cancel()
+ task = nil
+ }
+
+ static func startDetachedLoop(
+ task: inout Task?,
+ interval: TimeInterval,
+ operation: @escaping @Sendable () async -> Void)
+ {
+ guard task == nil else { return }
+ task = Task.detached {
+ await operation()
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
+ await operation()
+ }
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift b/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift
new file mode 100644
index 000000000000..114b3cdd4c57
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/SystemSettingsURLSupport.swift
@@ -0,0 +1,12 @@
+import AppKit
+import Foundation
+
+enum SystemSettingsURLSupport {
+ static func openFirst(_ candidates: [String]) {
+ for candidate in candidates {
+ if let url = URL(string: candidate), NSWorkspace.shared.open(url) {
+ return
+ }
+ }
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/TalkOverlay.swift b/apps/macos/Sources/OpenClaw/TalkOverlay.swift
index 27e5dedc1109..f72871d28cae 100644
--- a/apps/macos/Sources/OpenClaw/TalkOverlay.swift
+++ b/apps/macos/Sources/OpenClaw/TalkOverlay.swift
@@ -30,21 +30,11 @@ final class TalkOverlayController {
self.ensureWindow()
self.hostingView?.rootView = TalkOverlayView(controller: self)
let target = self.targetFrame()
-
- guard let window else { return }
- if !self.model.isVisible {
- self.model.isVisible = true
- let start = target.offsetBy(dx: 0, dy: -6)
- window.setFrame(start, display: true)
- window.alphaValue = 0
- window.orderFrontRegardless()
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 1
- }
- } else {
+ OverlayPanelFactory.present(
+ window: self.window,
+ isVisible: &self.model.isVisible,
+ target: target)
+ { window in
window.setFrame(target, display: true)
window.orderFrontRegardless()
}
@@ -56,13 +46,7 @@ final class TalkOverlayController {
return
}
- let target = window.frame.offsetBy(dx: 6, dy: 6)
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.16
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 0
- } completionHandler: {
+ OverlayPanelFactory.animateDismiss(window: window) {
Task { @MainActor in
window.orderOut(nil)
self.model.isVisible = false
@@ -100,23 +84,11 @@ final class TalkOverlayController {
private func ensureWindow() {
if self.window != nil { return }
- let panel = NSPanel(
+ let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: Self.overlaySize, height: Self.overlaySize),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false)
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.hasShadow = false
- panel.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4)
- panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
- panel.hidesOnDeactivate = false
- panel.isMovable = false
- panel.acceptsMouseMovedEvents = true
- panel.isFloatingPanel = true
- panel.becomesKeyOnlyIfNeeded = true
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
+ level: NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue - 4),
+ hasShadow: false,
+ acceptsMouseMovedEvents: true)
let host = TalkOverlayHostingView(rootView: TalkOverlayView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
diff --git a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift
index 80599d55ec33..25d3b78b75d1 100644
--- a/apps/macos/Sources/OpenClaw/TalkOverlayView.swift
+++ b/apps/macos/Sources/OpenClaw/TalkOverlayView.swift
@@ -53,18 +53,7 @@ struct TalkOverlayView: View {
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
private var seamColor: Color {
- Self.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
- }
-
- private static func color(fromHex raw: String?) -> Color? {
- let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
- guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
- let r = Double((value >> 16) & 0xFF) / 255.0
- let g = Double((value >> 8) & 0xFF) / 255.0
- let b = Double(value & 0xFF) / 255.0
- return Color(red: r, green: g, blue: b)
+ ColorHexSupport.color(fromHex: self.appState.seamColorHex) ?? Self.defaultSeamColor
}
}
diff --git a/apps/macos/Sources/OpenClaw/TextSummarySupport.swift b/apps/macos/Sources/OpenClaw/TextSummarySupport.swift
new file mode 100644
index 000000000000..a58caf8800f0
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/TextSummarySupport.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+enum TextSummarySupport {
+ static func summarizeLastLine(_ text: String, maxLength: Int = 200) -> String? {
+ let lines = text
+ .split(whereSeparator: \.isNewline)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ guard let last = lines.last else { return nil }
+ let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
+ if normalized.count > maxLength {
+ return String(normalized.prefix(maxLength - 1)) + "…"
+ }
+ return normalized
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift b/apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift
new file mode 100644
index 000000000000..eda52a994326
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/TrackingAreaSupport.swift
@@ -0,0 +1,22 @@
+import AppKit
+
+enum TrackingAreaSupport {
+ @MainActor
+ static func resetMouseTracking(
+ on view: NSView,
+ tracking: inout NSTrackingArea?,
+ owner: AnyObject)
+ {
+ if let tracking {
+ view.removeTrackingArea(tracking)
+ }
+ let options: NSTrackingArea.Options = [
+ .mouseEnteredAndExited,
+ .activeAlways,
+ .inVisibleRect,
+ ]
+ let area = NSTrackingArea(rect: view.bounds, options: options, owner: owner, userInfo: nil)
+ view.addTrackingArea(area)
+ tracking = area
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/UsageCostData.swift b/apps/macos/Sources/OpenClaw/UsageCostData.swift
index ca1fb5cc3e2a..3327a2a258f2 100644
--- a/apps/macos/Sources/OpenClaw/UsageCostData.swift
+++ b/apps/macos/Sources/OpenClaw/UsageCostData.swift
@@ -12,13 +12,92 @@ struct GatewayCostUsageTotals: Codable {
struct GatewayCostUsageDay: Codable {
let date: String
- let input: Int
- let output: Int
- let cacheRead: Int
- let cacheWrite: Int
- let totalTokens: Int
- let totalCost: Double
- let missingCostEntries: Int
+ private let totals: GatewayCostUsageTotals
+
+ var input: Int {
+ self.totals.input
+ }
+
+ var output: Int {
+ self.totals.output
+ }
+
+ var cacheRead: Int {
+ self.totals.cacheRead
+ }
+
+ var cacheWrite: Int {
+ self.totals.cacheWrite
+ }
+
+ var totalTokens: Int {
+ self.totals.totalTokens
+ }
+
+ var totalCost: Double {
+ self.totals.totalCost
+ }
+
+ var missingCostEntries: Int {
+ self.totals.missingCostEntries
+ }
+
+ init(
+ date: String,
+ input: Int,
+ output: Int,
+ cacheRead: Int,
+ cacheWrite: Int,
+ totalTokens: Int,
+ totalCost: Double,
+ missingCostEntries: Int)
+ {
+ self.date = date
+ self.totals = GatewayCostUsageTotals(
+ input: input,
+ output: output,
+ cacheRead: cacheRead,
+ cacheWrite: cacheWrite,
+ totalTokens: totalTokens,
+ totalCost: totalCost,
+ missingCostEntries: missingCostEntries)
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case date
+ case input
+ case output
+ case cacheRead
+ case cacheWrite
+ case totalTokens
+ case totalCost
+ case missingCostEntries
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ self.date = try c.decode(String.self, forKey: .date)
+ 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 {
+ var c = encoder.container(keyedBy: CodingKeys.self)
+ try c.encode(self.date, forKey: .date)
+ try c.encode(self.input, forKey: .input)
+ try c.encode(self.output, forKey: .output)
+ try c.encode(self.cacheRead, forKey: .cacheRead)
+ try c.encode(self.cacheWrite, forKey: .cacheWrite)
+ try c.encode(self.totalTokens, forKey: .totalTokens)
+ try c.encode(self.totalCost, forKey: .totalCost)
+ try c.encode(self.missingCostEntries, forKey: .missingCostEntries)
+ }
}
struct GatewayCostUsageSummary: Codable {
diff --git a/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift b/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift
index c7f95e476605..0119b527f99d 100644
--- a/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift
+++ b/apps/macos/Sources/OpenClaw/UsageMenuLabelView.swift
@@ -9,14 +9,6 @@ struct UsageMenuLabelView: View {
private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6
- private var primaryTextColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
- }
-
- private var secondaryTextColor: Color {
- self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
- }
-
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let used = row.usedPercent {
@@ -30,7 +22,7 @@ struct UsageMenuLabelView: View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text(self.row.titleText)
.font(.caption.weight(.semibold))
- .foregroundStyle(self.primaryTextColor)
+ .foregroundStyle(MenuItemHighlightColors.primary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
@@ -39,7 +31,7 @@ struct UsageMenuLabelView: View {
Text(self.row.detailText())
.font(.caption.monospacedDigit())
- .foregroundStyle(self.secondaryTextColor)
+ .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(2)
@@ -47,7 +39,7 @@ struct UsageMenuLabelView: View {
if self.showsChevron {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
- .foregroundStyle(self.secondaryTextColor)
+ .foregroundStyle(MenuItemHighlightColors.secondary(self.isHighlighted))
.padding(.leading, 2)
}
}
diff --git a/apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift b/apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift
new file mode 100644
index 000000000000..722a522f867e
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/VoiceOverlayTextFormatting.swift
@@ -0,0 +1,27 @@
+import AppKit
+
+enum VoiceOverlayTextFormatting {
+ static func delta(after committed: String, current: String) -> String {
+ if current.hasPrefix(committed) {
+ let start = current.index(current.startIndex, offsetBy: committed.count)
+ return String(current[start...])
+ }
+ return current
+ }
+
+ static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
+ let full = NSMutableAttributedString()
+ let committedAttr: [NSAttributedString.Key: Any] = [
+ .foregroundColor: NSColor.labelColor,
+ .font: NSFont.systemFont(ofSize: 13, weight: .regular),
+ ]
+ full.append(NSAttributedString(string: committed, attributes: committedAttr))
+ let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor
+ let volatileAttr: [NSAttributedString.Key: Any] = [
+ .foregroundColor: volatileColor,
+ .font: NSFont.systemFont(ofSize: 13, weight: .regular),
+ ]
+ full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
+ return full
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift
index 6eaa45e06759..1a76804b2470 100644
--- a/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift
+++ b/apps/macos/Sources/OpenClaw/VoicePushToTalk.swift
@@ -170,10 +170,11 @@ actor VoicePushToTalk {
// Pause the always-on wake word recognizer so both pipelines don't fight over the mic tap.
await VoiceWakeRuntime.shared.pauseForPushToTalk()
let adoptedPrefix = self.adoptedPrefix
- let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : Self.makeAttributed(
- committed: adoptedPrefix,
- volatile: "",
- isFinal: false)
+ let adoptedAttributed: NSAttributedString? = adoptedPrefix.isEmpty ? nil : VoiceOverlayTextFormatting
+ .makeAttributed(
+ committed: adoptedPrefix,
+ volatile: "",
+ isFinal: false)
self.overlayToken = await MainActor.run {
VoiceSessionCoordinator.shared.startSession(
source: .pushToTalk,
@@ -292,12 +293,15 @@ actor VoicePushToTalk {
self.committed = transcript
self.volatile = ""
} else {
- self.volatile = Self.delta(after: self.committed, current: transcript)
+ self.volatile = VoiceOverlayTextFormatting.delta(after: self.committed, current: transcript)
}
let committedWithPrefix = Self.join(self.adoptedPrefix, self.committed)
let snapshot = Self.join(committedWithPrefix, self.volatile)
- let attributed = Self.makeAttributed(committed: committedWithPrefix, volatile: self.volatile, isFinal: isFinal)
+ let attributed = VoiceOverlayTextFormatting.makeAttributed(
+ committed: committedWithPrefix,
+ volatile: self.volatile,
+ isFinal: isFinal)
if let token = self.overlayToken {
await MainActor.run {
VoiceSessionCoordinator.shared.updatePartial(
@@ -387,11 +391,11 @@ actor VoicePushToTalk {
// MARK: - Test helpers
static func _testDelta(committed: String, current: String) -> String {
- self.delta(after: committed, current: current)
+ VoiceOverlayTextFormatting.delta(after: committed, current: current)
}
static func _testAttributedColors(isFinal: Bool) -> (NSColor, NSColor) {
- let sample = self.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal)
+ let sample = VoiceOverlayTextFormatting.makeAttributed(committed: "a", volatile: "b", isFinal: isFinal)
let committedColor = sample.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
let volatileColor = sample.attribute(.foregroundColor, at: 1, effectiveRange: nil) as? NSColor ?? .clear
return (committedColor, volatileColor)
@@ -402,28 +406,4 @@ actor VoicePushToTalk {
if suffix.isEmpty { return prefix }
return "\(prefix) \(suffix)"
}
-
- private static func delta(after committed: String, current: String) -> String {
- if current.hasPrefix(committed) {
- let start = current.index(current.startIndex, offsetBy: committed.count)
- return String(current[start...])
- }
- return current
- }
-
- private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
- let full = NSMutableAttributedString()
- let committedAttr: [NSAttributedString.Key: Any] = [
- .foregroundColor: NSColor.labelColor,
- .font: NSFont.systemFont(ofSize: 13, weight: .regular),
- ]
- full.append(NSAttributedString(string: committed, attributes: committedAttr))
- let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor
- let volatileAttr: [NSAttributedString.Key: Any] = [
- .foregroundColor: volatileColor,
- .font: NSFont.systemFont(ofSize: 13, weight: .regular),
- ]
- full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
- return full
- }
}
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift
index af4fae356ee1..f8af69c066b6 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeGlobalSettingsSync.swift
@@ -14,8 +14,7 @@ final class VoiceWakeGlobalSettingsSync {
}
func start() {
- guard self.task == nil else { return }
- self.task = Task { [weak self] in
+ SimpleTaskSupport.start(task: &self.task) { [weak self] in
guard let self else { return }
while !Task.isCancelled {
do {
@@ -39,8 +38,7 @@ final class VoiceWakeGlobalSettingsSync {
}
func stop() {
- self.task?.cancel()
- self.task = nil
+ SimpleTaskSupport.stop(task: &self.task)
}
private func refreshFromGateway() async {
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift
index fb5526a8d450..9575dde52bb8 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeOverlayController+Window.swift
@@ -13,50 +13,30 @@ extension VoiceWakeOverlayController {
self.ensureWindow()
self.hostingView?.rootView = VoiceWakeOverlayView(controller: self)
let target = self.targetFrame()
-
- guard let window else { return }
- if !self.model.isVisible {
- self.model.isVisible = true
- self.logger.log(
- level: .info,
- "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)
- let start = target.offsetBy(dx: 0, dy: -6)
- window.setFrame(start, display: true)
- window.alphaValue = 0
- window.orderFrontRegardless()
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(target, display: true)
- window.animator().alphaValue = 1
- }
- } else {
- self.updateWindowFrame(animate: true)
- window.orderFrontRegardless()
- }
+ OverlayPanelFactory.present(
+ window: self.window,
+ isVisible: &self.model.isVisible,
+ target: target,
+ onFirstPresent: {
+ self.logger.log(
+ level: .info,
+ "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)
+ },
+ onAlreadyVisible: { window in
+ self.updateWindowFrame(animate: true)
+ window.orderFrontRegardless()
+ })
}
private func ensureWindow() {
if self.window != nil { return }
let borderPad = self.closeOverflow
- let panel = NSPanel(
+ let panel = OverlayPanelFactory.makePanel(
contentRect: NSRect(x: 0, y: 0, width: self.width + borderPad * 2, height: 60 + borderPad * 2),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false)
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.hasShadow = false
- panel.level = Self.preferredWindowLevel
- panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
- panel.hidesOnDeactivate = false
- panel.isMovable = false
- panel.isFloatingPanel = true
- panel.becomesKeyOnlyIfNeeded = true
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
+ level: Self.preferredWindowLevel,
+ hasShadow: false)
let host = NSHostingView(rootView: VoiceWakeOverlayView(controller: self))
host.translatesAutoresizingMaskIntoConstraints = false
@@ -84,17 +64,7 @@ extension VoiceWakeOverlayController {
}
func updateWindowFrame(animate: Bool = false) {
- guard let window else { return }
- let frame = self.targetFrame()
- if animate {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.12
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- window.animator().setFrame(frame, display: true)
- }
- } else {
- window.setFrame(frame, display: true)
- }
+ OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate)
}
func measuredHeight() -> CGFloat {
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift
new file mode 100644
index 000000000000..8dc29b93de8d
--- /dev/null
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeRecognitionDebugSupport.swift
@@ -0,0 +1,62 @@
+import Foundation
+import SwabbleKit
+
+enum VoiceWakeRecognitionDebugSupport {
+ struct TranscriptSummary {
+ let textOnly: Bool
+ let timingCount: Int
+ }
+
+ static func shouldLogTranscript(
+ transcript: String,
+ isFinal: Bool,
+ loggerLevel: Logger.Level,
+ lastLoggedText: inout String?,
+ lastLoggedAt: inout Date?,
+ minRepeatInterval: TimeInterval = 0.25) -> Bool
+ {
+ guard !transcript.isEmpty else { return false }
+ guard loggerLevel == .debug || loggerLevel == .trace else { return false }
+ if transcript == lastLoggedText,
+ !isFinal,
+ let last = lastLoggedAt,
+ Date().timeIntervalSince(last) < minRepeatInterval
+ {
+ return false
+ }
+ lastLoggedText = transcript
+ lastLoggedAt = Date()
+ return true
+ }
+
+ static func textOnlyFallbackMatch(
+ transcript: String,
+ triggers: [String],
+ config: WakeWordGateConfig,
+ trimWake: (String, [String]) -> String) -> WakeWordGateMatch?
+ {
+ guard let command = VoiceWakeTextUtils.textOnlyCommand(
+ transcript: transcript,
+ triggers: triggers,
+ minCommandLength: config.minCommandLength,
+ trimWake: trimWake)
+ else { return nil }
+ return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
+ }
+
+ static func transcriptSummary(
+ transcript: String,
+ triggers: [String],
+ segments: [WakeWordSegment]) -> TranscriptSummary
+ {
+ TranscriptSummary(
+ textOnly: WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers),
+ timingCount: segments.count(where: { $0.start > 0 || $0.duration > 0 }))
+ }
+
+ static func matchSummary(_ match: WakeWordGateMatch?) -> String {
+ match.map {
+ "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
+ } ?? "match=false"
+ }
+}
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift
index b7e2d329b820..55775ecbe0ba 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeRuntime.swift
@@ -312,10 +312,12 @@ actor VoiceWakeRuntime {
self.committedTranscript = trimmed
self.volatileTranscript = ""
} else {
- self.volatileTranscript = Self.delta(after: self.committedTranscript, current: trimmed)
+ self.volatileTranscript = VoiceOverlayTextFormatting.delta(
+ after: self.committedTranscript,
+ current: trimmed)
}
- let attributed = Self.makeAttributed(
+ let attributed = VoiceOverlayTextFormatting.makeAttributed(
committed: self.committedTranscript,
volatile: self.volatileTranscript,
isFinal: update.isFinal)
@@ -337,10 +339,11 @@ actor VoiceWakeRuntime {
var usedFallback = false
var match = WakeWordGate.match(transcript: transcript, segments: update.segments, config: gateConfig)
if match == nil, update.isFinal {
- match = self.textOnlyFallbackMatch(
+ match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: transcript,
triggers: config.triggers,
- config: gateConfig)
+ config: gateConfig,
+ trimWake: Self.trimmedAfterTrigger)
usedFallback = match != nil
}
self.maybeLogRecognition(
@@ -387,22 +390,19 @@ actor VoiceWakeRuntime {
usedFallback: Bool,
capturing: Bool)
{
- guard !transcript.isEmpty else { return }
- let level = self.logger.logLevel
- guard level == .debug || level == .trace else { return }
- if transcript == self.lastLoggedText, !isFinal {
- if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
- return
- }
- }
- self.lastLoggedText = transcript
- self.lastLoggedAt = Date()
+ guard VoiceWakeRecognitionDebugSupport.shouldLogTranscript(
+ transcript: transcript,
+ isFinal: isFinal,
+ loggerLevel: self.logger.logLevel,
+ lastLoggedText: &self.lastLoggedText,
+ lastLoggedAt: &self.lastLoggedAt)
+ else { return }
- let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers)
- let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
- let matchSummary = match.map {
- "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
- } ?? "match=false"
+ let summary = VoiceWakeRecognitionDebugSupport.transcriptSummary(
+ transcript: transcript,
+ triggers: triggers,
+ segments: segments)
+ let matchSummary = VoiceWakeRecognitionDebugSupport.matchSummary(match)
let segmentSummary = segments.map { seg in
let start = String(format: "%.2f", seg.start)
let end = String(format: "%.2f", seg.end)
@@ -410,8 +410,8 @@ actor VoiceWakeRuntime {
}.joined(separator: ", ")
self.logger.debug(
- "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
- "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
+ "voicewake runtime transcript='\(transcript, privacy: .private)' textOnly=\(summary.textOnly) " +
+ "isFinal=\(isFinal) timing=\(summary.timingCount)/\(segments.count) " +
"capturing=\(capturing) fallback=\(usedFallback) " +
"\(matchSummary) segments=[\(segmentSummary, privacy: .private)]")
}
@@ -495,20 +495,6 @@ actor VoiceWakeRuntime {
await self.beginCapture(command: "", triggerEndTime: nil, config: config)
}
- private func textOnlyFallbackMatch(
- transcript: String,
- triggers: [String],
- config: WakeWordGateConfig) -> WakeWordGateMatch?
- {
- guard let command = VoiceWakeTextUtils.textOnlyCommand(
- transcript: transcript,
- triggers: triggers,
- minCommandLength: config.minCommandLength,
- trimWake: Self.trimmedAfterTrigger)
- else { return nil }
- return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
- }
-
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false }
@@ -526,10 +512,11 @@ actor VoiceWakeRuntime {
guard !self.isCapturing else { return }
guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
- guard let match = self.textOnlyFallbackMatch(
+ guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: lastText,
triggers: triggers,
- config: gateConfig)
+ config: gateConfig,
+ trimWake: Self.trimmedAfterTrigger)
else { return }
if let cooldown = self.cooldownUntil, Date() < cooldown {
return
@@ -564,7 +551,7 @@ actor VoiceWakeRuntime {
}
let snapshot = self.committedTranscript + self.volatileTranscript
- let attributed = Self.makeAttributed(
+ let attributed = VoiceOverlayTextFormatting.makeAttributed(
committed: self.committedTranscript,
volatile: self.volatileTranscript,
isFinal: false)
@@ -781,33 +768,9 @@ actor VoiceWakeRuntime {
}
static func _testAttributedColor(isFinal: Bool) -> NSColor {
- self.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal)
+ VoiceOverlayTextFormatting.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal)
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
}
#endif
-
- private static func delta(after committed: String, current: String) -> String {
- if current.hasPrefix(committed) {
- let start = current.index(current.startIndex, offsetBy: committed.count)
- return String(current[start...])
- }
- return current
- }
-
- private static func makeAttributed(committed: String, volatile: String, isFinal: Bool) -> NSAttributedString {
- let full = NSMutableAttributedString()
- let committedAttr: [NSAttributedString.Key: Any] = [
- .foregroundColor: NSColor.labelColor,
- .font: NSFont.systemFont(ofSize: 13, weight: .regular),
- ]
- full.append(NSAttributedString(string: committed, attributes: committedAttr))
- let volatileColor: NSColor = isFinal ? .labelColor : NSColor.tertiaryLabelColor
- let volatileAttr: [NSAttributedString.Key: Any] = [
- .foregroundColor: volatileColor,
- .font: NSFont.systemFont(ofSize: 13, weight: .regular),
- ]
- full.append(NSAttributedString(string: volatile, attributes: volatileAttr))
- return full
- }
}
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
index d4413618e11c..a8db70378930 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeSettings.swift
@@ -40,11 +40,7 @@ struct VoiceWakeSettings: View {
}
private var voiceWakeBinding: Binding {
- Binding(
- get: { self.state.swabbleEnabled },
- set: { newValue in
- Task { await self.state.setVoiceWakeEnabled(newValue) }
- })
+ MicRefreshSupport.voiceWakeBinding(for: self.state)
}
var body: some View {
@@ -534,30 +530,22 @@ struct VoiceWakeSettings: View {
@MainActor
private func updateSelectedMicName() {
- let selected = self.state.voiceWakeMicID
- if selected.isEmpty {
- self.state.voiceWakeMicName = ""
- return
- }
- if let match = self.availableMics.first(where: { $0.uid == selected }) {
- self.state.voiceWakeMicName = match.name
- }
+ self.state.voiceWakeMicName = MicRefreshSupport.selectedMicName(
+ selectedID: self.state.voiceWakeMicID,
+ in: self.availableMics,
+ uid: \.uid,
+ name: \.name)
}
private func startMicObserver() {
- self.micObserver.start {
- Task { @MainActor in
- self.scheduleMicRefresh()
- }
+ MicRefreshSupport.startObserver(self.micObserver) {
+ self.scheduleMicRefresh()
}
}
@MainActor
private func scheduleMicRefresh() {
- self.micRefreshTask?.cancel()
- self.micRefreshTask = Task { @MainActor in
- try? await Task.sleep(nanoseconds: 300_000_000)
- guard !Task.isCancelled else { return }
+ MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
await self.loadMicsIfNeeded(force: true)
await self.restartMeter()
}
diff --git a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift
index 063fea826ab6..906f4a1c8b71 100644
--- a/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift
+++ b/apps/macos/Sources/OpenClaw/VoiceWakeTester.swift
@@ -140,10 +140,11 @@ final class VoiceWakeTester {
let gateConfig = WakeWordGateConfig(triggers: triggers)
var match = WakeWordGate.match(transcript: text, segments: segments, config: gateConfig)
if match == nil, isFinal {
- match = self.textOnlyFallbackMatch(
+ match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: text,
triggers: triggers,
- config: gateConfig)
+ config: gateConfig,
+ trimWake: WakeWordGate.stripWake)
}
self.maybeLogDebug(
transcript: text,
@@ -273,28 +274,25 @@ final class VoiceWakeTester {
match: WakeWordGateMatch?,
isFinal: Bool)
{
- guard !transcript.isEmpty else { return }
- let level = self.logger.logLevel
- guard level == .debug || level == .trace else { return }
- if transcript == self.lastLoggedText, !isFinal {
- if let last = self.lastLoggedAt, Date().timeIntervalSince(last) < 0.25 {
- return
- }
- }
- self.lastLoggedText = transcript
- self.lastLoggedAt = Date()
+ guard VoiceWakeRecognitionDebugSupport.shouldLogTranscript(
+ transcript: transcript,
+ isFinal: isFinal,
+ loggerLevel: self.logger.logLevel,
+ lastLoggedText: &self.lastLoggedText,
+ lastLoggedAt: &self.lastLoggedAt)
+ else { return }
- let textOnly = WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers)
+ let summary = VoiceWakeRecognitionDebugSupport.transcriptSummary(
+ transcript: transcript,
+ triggers: triggers,
+ segments: segments)
let gaps = Self.debugCandidateGaps(triggers: triggers, segments: segments)
let segmentSummary = Self.debugSegments(segments)
- let timingCount = segments.count(where: { $0.start > 0 || $0.duration > 0 })
- let matchSummary = match.map {
- "match=true gap=\(String(format: "%.2f", $0.postGap))s cmdLen=\($0.command.count)"
- } ?? "match=false"
+ let matchSummary = VoiceWakeRecognitionDebugSupport.matchSummary(match)
self.logger.debug(
- "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(textOnly) " +
- "isFinal=\(isFinal) timing=\(timingCount)/\(segments.count) " +
+ "voicewake test transcript='\(transcript, privacy: .private)' textOnly=\(summary.textOnly) " +
+ "isFinal=\(isFinal) timing=\(summary.timingCount)/\(segments.count) " +
"\(matchSummary) gaps=[\(gaps, privacy: .private)] segments=[\(segmentSummary, privacy: .private)]")
}
@@ -362,20 +360,6 @@ final class VoiceWakeTester {
}
}
- private func textOnlyFallbackMatch(
- transcript: String,
- triggers: [String],
- config: WakeWordGateConfig) -> WakeWordGateMatch?
- {
- guard let command = VoiceWakeTextUtils.textOnlyCommand(
- transcript: transcript,
- triggers: triggers,
- minCommandLength: config.minCommandLength,
- trimWake: { WakeWordGate.stripWake(text: $0, triggers: $1) })
- else { return nil }
- return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
- }
-
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
Task { [weak self] in
guard let self else { return }
@@ -415,10 +399,12 @@ final class VoiceWakeTester {
guard !self.isStopping, !self.holdingAfterDetect else { return }
guard let lastSeenAt, let lastText else { return }
guard self.lastTranscriptAt == lastSeenAt, self.lastTranscript == lastText else { return }
- guard let match = self.textOnlyFallbackMatch(
+ guard let match = VoiceWakeRecognitionDebugSupport.textOnlyFallbackMatch(
transcript: lastText,
triggers: triggers,
- config: WakeWordGateConfig(triggers: triggers)) else { return }
+ config: WakeWordGateConfig(triggers: triggers),
+ trimWake: WakeWordGate.stripWake)
+ else { return }
self.holdingAfterDetect = true
self.detectedText = match.command
self.logger.info("voice wake detected (test, silence) (len=\(match.command.count))")
diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift
index 61d1b4d39b7b..47a8c781b8af 100644
--- a/apps/macos/Sources/OpenClaw/WebChatManager.swift
+++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift
@@ -111,13 +111,7 @@ final class WebChatManager {
}
func close() {
- self.windowController?.close()
- self.windowController = nil
- self.windowSessionKey = nil
- self.panelController?.close()
- self.panelController = nil
- self.panelSessionKey = nil
- self.cachedPreferredSessionKey = nil
+ self.resetTunnels()
}
private func panelHidden() {
diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
index 46e5d80a01eb..61e19d913818 100644
--- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
+++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift
@@ -251,10 +251,7 @@ final class WebChatSwiftUIWindowController {
}
private func removeDismissMonitor() {
- if let monitor = self.dismissMonitor {
- NSEvent.removeMonitor(monitor)
- self.dismissMonitor = nil
- }
+ OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor)
}
private static func makeWindow(
@@ -371,13 +368,6 @@ final class WebChatSwiftUIWindowController {
}
private static func color(fromHex raw: String?) -> Color? {
- let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { return nil }
- let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
- guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
- let r = Double((value >> 16) & 0xFF) / 255.0
- let g = Double((value >> 8) & 0xFF) / 255.0
- let b = Double(value & 0xFF) / 255.0
- return Color(red: r, green: g, blue: b)
+ ColorHexSupport.color(fromHex: raw)
}
}
diff --git a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift
index 77d629630300..ac339a25317a 100644
--- a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift
+++ b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift
@@ -113,17 +113,15 @@ final class WorkActivityStore {
private func setJobActive(_ activity: Activity) {
self.jobs[activity.sessionKey] = activity
- // Main session preempts immediately.
- if activity.role == .main {
- self.currentSessionKey = activity.sessionKey
- } else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) {
- self.currentSessionKey = activity.sessionKey
- }
- self.refreshDerivedState()
+ self.updateCurrentSession(with: activity)
}
private func setToolActive(_ activity: Activity) {
self.tools[activity.sessionKey] = activity
+ self.updateCurrentSession(with: activity)
+ }
+
+ private func updateCurrentSession(with activity: Activity) {
// Main session preempts immediately.
if activity.role == .main {
self.currentSessionKey = activity.sessionKey
diff --git a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
index abd18efaa9a4..94361421a98a 100644
--- a/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
+++ b/apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
@@ -92,31 +92,22 @@ public final class GatewayDiscoveryModel {
if !self.browsers.isEmpty { return }
for domain in OpenClawBonjour.gatewayServiceDomains {
- let params = NWParameters.tcp
- params.includePeerToPeer = true
- let browser = NWBrowser(
- for: .bonjour(type: OpenClawBonjour.gatewayServiceType, domain: domain),
- using: params)
-
- browser.stateUpdateHandler = { [weak self] state in
- Task { @MainActor in
+ let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
+ serviceType: OpenClawBonjour.gatewayServiceType,
+ domain: domain,
+ queueLabelPrefix: "ai.openclaw.macos.gateway-discovery",
+ onState: { [weak self] state in
guard let self else { return }
self.statesByDomain[domain] = state
self.updateStatusText()
- }
- }
-
- browser.browseResultsChangedHandler = { [weak self] results, _ in
- Task { @MainActor in
+ },
+ onResults: { [weak self] results in
guard let self else { return }
self.resultsByDomain[domain] = results
self.updateGateways(for: domain)
self.recomputeGateways()
- }
- }
-
+ })
self.browsers[domain] = browser
- browser.start(queue: DispatchQueue(label: "ai.openclaw.macos.gateway-discovery.\(domain)"))
}
self.scheduleWideAreaFallback()
@@ -617,8 +608,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
}
func start(timeout: TimeInterval = 2.0) {
- self.service.schedule(in: .main, forMode: .common)
- self.service.resolve(withTimeout: timeout)
+ BonjourServiceResolverSupport.start(self.service, timeout: timeout)
}
func cancel() {
@@ -664,9 +654,7 @@ final class GatewayServiceResolver: NSObject, NetServiceDelegate {
}
private static func normalizeHost(_ raw: String?) -> String? {
- let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmed.isEmpty { return nil }
- return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
+ BonjourServiceResolverSupport.normalizeHost(raw)
}
private func formatTXT(_ txt: [String: String]) -> String {
diff --git a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
index ef78e6f400ff..53bb738e6427 100644
--- a/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
+++ b/apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
@@ -1,5 +1,5 @@
-import Darwin
import Foundation
+import OpenClawKit
public enum TailscaleNetwork {
public static func isTailnetIPv4(_ address: String) -> Bool {
@@ -13,34 +13,9 @@ public enum TailscaleNetwork {
}
public static func detectTailnetIPv4() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
- if self.isTailnetIPv4(ip) { return ip }
+ for entry in NetworkInterfaceIPv4.addresses() where self.isTailnetIPv4(entry.ip) {
+ return entry.ip
}
-
return nil
}
}
diff --git a/apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift b/apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift
new file mode 100644
index 000000000000..d23c8bcc1770
--- /dev/null
+++ b/apps/macos/Sources/OpenClawMacCLI/CLIArgParsingSupport.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+enum CLIArgParsingSupport {
+ static func nextValue(_ args: [String], index: inout Int) -> String? {
+ guard index + 1 < args.count else { return nil }
+ index += 1
+ return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
diff --git a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift
index 151b7fdda94c..adf2d8599c34 100644
--- a/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift
+++ b/apps/macos/Sources/OpenClawMacCLI/ConnectCommand.swift
@@ -53,7 +53,7 @@ struct ConnectOptions {
i += 1
continue
}
- if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
+ if let handler = valueHandlers[arg], let value = CLIArgParsingSupport.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
@@ -62,12 +62,6 @@ struct ConnectOptions {
}
return opts
}
-
- private static func nextValue(_ args: [String], index: inout Int) -> String? {
- guard index + 1 < args.count else { return nil }
- index += 1
- return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
- }
}
struct ConnectOutput: Encodable {
@@ -233,14 +227,7 @@ private func printConnectOutput(_ output: ConnectOutput, json: Bool) {
private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint {
let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased()
if let raw = opts.url, !raw.isEmpty {
- guard let url = URL(string: raw) else {
- throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
- }
- return GatewayEndpoint(
- url: url,
- token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
- password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
- mode: resolvedMode)
+ return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config)
}
if resolvedMode == "remote" {
@@ -252,14 +239,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
- guard let url = URL(string: raw) else {
- throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
- }
- return GatewayEndpoint(
- url: url,
- token: resolvedToken(opts: opts, mode: resolvedMode, config: config),
- password: resolvedPassword(opts: opts, mode: resolvedMode, config: config),
- mode: resolvedMode)
+ return try gatewayEndpoint(fromRawURL: raw, opts: opts, mode: resolvedMode, config: config)
}
let port = config.port ?? 18789
@@ -281,6 +261,22 @@ private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) ->
try? resolveGatewayEndpoint(opts: opts, config: config)
}
+private func gatewayEndpoint(
+ fromRawURL raw: String,
+ opts: ConnectOptions,
+ mode: String,
+ config: GatewayConfig) throws -> GatewayEndpoint
+{
+ guard let url = URL(string: raw) else {
+ throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
+ }
+ return GatewayEndpoint(
+ url: url,
+ token: resolvedToken(opts: opts, mode: mode, config: config),
+ password: resolvedPassword(opts: opts, mode: mode, config: config),
+ mode: mode)
+}
+
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
if let token = opts.token, !token.isEmpty { return token }
if mode == "remote" {
diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift
index f75ef05fdb2e..26ccdb0e0a64 100644
--- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift
+++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift
@@ -23,17 +23,17 @@ struct WizardCliOptions {
case "--json":
opts.json = true
case "--url":
- opts.url = self.nextValue(args, index: &i)
+ opts.url = CLIArgParsingSupport.nextValue(args, index: &i)
case "--token":
- opts.token = self.nextValue(args, index: &i)
+ opts.token = CLIArgParsingSupport.nextValue(args, index: &i)
case "--password":
- opts.password = self.nextValue(args, index: &i)
+ opts.password = CLIArgParsingSupport.nextValue(args, index: &i)
case "--mode":
- if let value = nextValue(args, index: &i) {
+ if let value = CLIArgParsingSupport.nextValue(args, index: &i) {
opts.mode = value
}
case "--workspace":
- opts.workspace = self.nextValue(args, index: &i)
+ opts.workspace = CLIArgParsingSupport.nextValue(args, index: &i)
default:
break
}
@@ -41,12 +41,6 @@ struct WizardCliOptions {
}
return opts
}
-
- private static func nextValue(_ args: [String], index: inout Int) -> String? {
- guard index + 1 < args.count else { return nil }
- index += 1
- return args[index].trimmingCharacters(in: .whitespacesAndNewlines)
- }
}
enum WizardCliError: Error, CustomStringConvertible {
@@ -291,16 +285,12 @@ actor GatewayWizardClient {
nonce: connectNonce,
platform: platform,
deviceFamily: "Mac")
- if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
- let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
+ if let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
+ payload: payload,
+ identity: identity,
+ signedAtMs: signedAtMs,
+ nonce: connectNonce)
{
- let device: [String: ProtoAnyCodable] = [
- "id": ProtoAnyCodable(identity.deviceId),
- "publicKey": ProtoAnyCodable(publicKey),
- "signature": ProtoAnyCodable(signature),
- "signedAt": ProtoAnyCodable(signedAtMs),
- "nonce": ProtoAnyCodable(connectNonce),
- ]
params["device"] = ProtoAnyCodable(device)
}
@@ -338,8 +328,7 @@ actor GatewayWizardClient {
let frame = try await self.decodeFrame(message)
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
- let nonce = payload["nonce"]?.value as? String,
- nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
+ let nonce = GatewayConnectChallengeSupport.nonce(from: payload)
{
return nonce
}
diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
index 7aa2933479bb..6d138c70525d 100644
--- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift
@@ -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?
diff --git a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift
index 8810d12385b9..ef7604729019 100644
--- a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift
@@ -5,23 +5,44 @@ import Testing
private typealias SnapshotAnyCodable = OpenClaw.AnyCodable
+private let channelOrder = ["whatsapp", "telegram", "signal", "imessage"]
+private let channelLabels = [
+ "whatsapp": "WhatsApp",
+ "telegram": "Telegram",
+ "signal": "Signal",
+ "imessage": "iMessage",
+]
+private let channelDefaultAccountId = [
+ "whatsapp": "default",
+ "telegram": "default",
+ "signal": "default",
+ "imessage": "default",
+]
+
+@MainActor
+private func makeChannelsStore(
+ channels: [String: SnapshotAnyCodable],
+ ts: Double = 1_700_000_000_000) -> ChannelsStore
+{
+ let store = ChannelsStore(isPreview: true)
+ store.snapshot = ChannelsStatusSnapshot(
+ ts: ts,
+ channelOrder: channelOrder,
+ channelLabels: channelLabels,
+ channelDetailLabels: nil,
+ channelSystemImages: nil,
+ channelMeta: nil,
+ channels: channels,
+ channelAccounts: [:],
+ channelDefaultAccountId: channelDefaultAccountId)
+ return store
+}
+
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@Test func channelsSettingsBuildsBodyWithSnapshot() {
- let store = ChannelsStore(isPreview: true)
- store.snapshot = ChannelsStatusSnapshot(
- ts: 1_700_000_000_000,
- channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
- channelLabels: [
- "whatsapp": "WhatsApp",
- "telegram": "Telegram",
- "signal": "Signal",
- "imessage": "iMessage",
- ],
- channelDetailLabels: nil,
- channelSystemImages: nil,
- channelMeta: nil,
+ let store = makeChannelsStore(
channels: [
"whatsapp": SnapshotAnyCodable([
"configured": true,
@@ -77,13 +98,6 @@ struct ChannelsSettingsSmokeTests {
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_050_000,
]),
- ],
- channelAccounts: [:],
- channelDefaultAccountId: [
- "whatsapp": "default",
- "telegram": "default",
- "signal": "default",
- "imessage": "default",
])
store.whatsappLoginMessage = "Scan QR"
@@ -95,19 +109,7 @@ struct ChannelsSettingsSmokeTests {
}
@Test func channelsSettingsBuildsBodyWithoutSnapshot() {
- let store = ChannelsStore(isPreview: true)
- store.snapshot = ChannelsStatusSnapshot(
- ts: 1_700_000_000_000,
- channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
- channelLabels: [
- "whatsapp": "WhatsApp",
- "telegram": "Telegram",
- "signal": "Signal",
- "imessage": "iMessage",
- ],
- channelDetailLabels: nil,
- channelSystemImages: nil,
- channelMeta: nil,
+ let store = makeChannelsStore(
channels: [
"whatsapp": SnapshotAnyCodable([
"configured": false,
@@ -149,13 +151,6 @@ struct ChannelsSettingsSmokeTests {
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_200_000,
]),
- ],
- channelAccounts: [:],
- channelDefaultAccountId: [
- "whatsapp": "default",
- "telegram": "default",
- "signal": "default",
- "imessage": "default",
])
let view = ChannelsSettings(store: store)
diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
index 0396daeeae1f..89fffd9dabf0 100644
--- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift
@@ -9,48 +9,45 @@ import Testing
UserDefaults(suiteName: "CommandResolverTests.\(UUID().uuidString)")!
}
- private func makeTempDir() throws -> URL {
- let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
- return dir
+ private func makeLocalDefaults() -> UserDefaults {
+ let defaults = self.makeDefaults()
+ defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
+ return defaults
}
- private func makeExec(at path: URL) throws {
- try FileManager().createDirectory(
- at: path.deletingLastPathComponent(),
- withIntermediateDirectories: true)
- FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
- try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
+ private func makeProjectRootWithPnpm() throws -> (tmp: URL, pnpmPath: URL) {
+ let tmp = try makeTempDirForTests()
+ CommandResolver.setProjectRoot(tmp.path)
+ let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
+ try makeExecutableForTests(at: pnpmPath)
+ return (tmp, pnpmPath)
}
@Test func prefersOpenClawBinary() throws {
- let defaults = self.makeDefaults()
- defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
+ let defaults = self.makeLocalDefaults()
- let tmp = try makeTempDir()
+ let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
- try self.makeExec(at: openclawPath)
+ try makeExecutableForTests(at: openclawPath)
let cmd = CommandResolver.openclawCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
#expect(cmd.prefix(2).elementsEqual([openclawPath.path, "gateway"]))
}
@Test func fallsBackToNodeAndScript() throws {
- let defaults = self.makeDefaults()
- defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
+ let defaults = self.makeLocalDefaults()
- let tmp = try makeTempDir()
+ let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
- try self.makeExec(at: nodePath)
+ try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
- try self.makeExec(at: scriptPath)
+ try makeExecutableForTests(at: scriptPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "rpc",
@@ -67,17 +64,16 @@ import Testing
}
@Test func prefersOpenClawBinaryOverPnpm() throws {
- let defaults = self.makeDefaults()
- defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
+ let defaults = self.makeLocalDefaults()
- let tmp = try makeTempDir()
+ let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let binDir = tmp.appendingPathComponent("bin")
let openclawPath = binDir.appendingPathComponent("openclaw")
let pnpmPath = binDir.appendingPathComponent("pnpm")
- try self.makeExec(at: openclawPath)
- try self.makeExec(at: pnpmPath)
+ try makeExecutableForTests(at: openclawPath)
+ try makeExecutableForTests(at: pnpmPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "rpc",
@@ -89,15 +85,14 @@ import Testing
}
@Test func usesOpenClawBinaryWithoutNodeRuntime() throws {
- let defaults = self.makeDefaults()
- defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
+ let defaults = self.makeLocalDefaults()
- let tmp = try makeTempDir()
+ let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let binDir = tmp.appendingPathComponent("bin")
let openclawPath = binDir.appendingPathComponent("openclaw")
- try self.makeExec(at: openclawPath)
+ try makeExecutableForTests(at: openclawPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "gateway",
@@ -109,14 +104,8 @@ import Testing
}
@Test func fallsBackToPnpm() throws {
- let defaults = self.makeDefaults()
- defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
-
- let tmp = try makeTempDir()
- CommandResolver.setProjectRoot(tmp.path)
-
- let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
- try self.makeExec(at: pnpmPath)
+ let defaults = self.makeLocalDefaults()
+ let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
let cmd = CommandResolver.openclawCommand(
subcommand: "rpc",
@@ -128,14 +117,8 @@ import Testing
}
@Test func pnpmKeepsExtraArgsAfterSubcommand() throws {
- let defaults = self.makeDefaults()
- defaults.set(AppState.ConnectionMode.local.rawValue, forKey: connectionModeKey)
-
- let tmp = try makeTempDir()
- CommandResolver.setProjectRoot(tmp.path)
-
- let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
- try self.makeExec(at: pnpmPath)
+ let defaults = self.makeLocalDefaults()
+ let (tmp, pnpmPath) = try self.makeProjectRootWithPnpm()
let cmd = CommandResolver.openclawCommand(
subcommand: "health",
@@ -149,7 +132,7 @@ import Testing
}
@Test func preferredPathsStartWithProjectNodeBins() throws {
- let tmp = try makeTempDir()
+ let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let first = CommandResolver.preferredPaths().first
@@ -198,11 +181,11 @@ import Testing
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
defaults.set("openclaw@example.com:2222", forKey: remoteTargetKey)
- let tmp = try makeTempDir()
+ let tmp = try makeTempDirForTests()
CommandResolver.setProjectRoot(tmp.path)
let openclawPath = tmp.appendingPathComponent("node_modules/.bin/openclaw")
- try self.makeExec(at: openclawPath)
+ try makeExecutableForTests(at: openclawPath)
let cmd = CommandResolver.openclawCommand(
subcommand: "daemon",
diff --git a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift
index 210e3e63bab3..d0304f070b14 100644
--- a/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/CronJobEditorSmokeTests.swift
@@ -5,20 +5,23 @@ import Testing
@Suite(.serialized)
@MainActor
struct CronJobEditorSmokeTests {
+ private func makeEditor(job: CronJob? = nil, channelsStore: ChannelsStore? = nil) -> CronJobEditor {
+ CronJobEditor(
+ job: job,
+ isSaving: .constant(false),
+ error: .constant(nil),
+ channelsStore: channelsStore ?? ChannelsStore(isPreview: true),
+ onCancel: {},
+ onSave: { _ in })
+ }
+
@Test func statusPillBuildsBody() {
_ = StatusPill(text: "ok", tint: .green).body
_ = StatusPill(text: "disabled", tint: .secondary).body
}
@Test func cronJobEditorBuildsBodyForNewJob() {
- let channelsStore = ChannelsStore(isPreview: true)
- let view = CronJobEditor(
- job: nil,
- isSaving: .constant(false),
- error: .constant(nil),
- channelsStore: channelsStore,
- onCancel: {},
- onSave: { _ in })
+ let view = self.makeEditor()
_ = view.body
}
@@ -53,37 +56,17 @@ struct CronJobEditorSmokeTests {
lastError: nil,
lastDurationMs: 1000))
- let view = CronJobEditor(
- job: job,
- isSaving: .constant(false),
- error: .constant(nil),
- channelsStore: channelsStore,
- onCancel: {},
- onSave: { _ in })
+ let view = self.makeEditor(job: job, channelsStore: channelsStore)
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
- let channelsStore = ChannelsStore(isPreview: true)
- var view = CronJobEditor(
- job: nil,
- isSaving: .constant(false),
- error: .constant(nil),
- channelsStore: channelsStore,
- onCancel: {},
- onSave: { _ in })
+ var view = self.makeEditor()
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() {
- let channelsStore = ChannelsStore(isPreview: true)
- let view = CronJobEditor(
- job: nil,
- isSaving: .constant(false),
- error: .constant(nil),
- channelsStore: channelsStore,
- onCancel: {},
- onSave: { _ in })
+ let view = self.makeEditor()
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
diff --git a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift
index f90ac25a9d72..c7e15184351b 100644
--- a/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/CronModelsTests.swift
@@ -4,6 +4,28 @@ import Testing
@Suite
struct CronModelsTests {
+ private func makeCronJob(
+ name: String,
+ payloadText: String,
+ state: CronJobState = CronJobState()) -> CronJob
+ {
+ CronJob(
+ id: "x",
+ agentId: nil,
+ name: name,
+ description: nil,
+ enabled: true,
+ deleteAfterRun: nil,
+ createdAtMs: 0,
+ updatedAtMs: 0,
+ schedule: .at(at: "2026-02-03T18:00:00Z"),
+ sessionTarget: .main,
+ wakeMode: .now,
+ payload: .systemEvent(text: payloadText),
+ delivery: nil,
+ state: state)
+ }
+
@Test func scheduleAtEncodesAndDecodes() throws {
let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z")
let data = try JSONEncoder().encode(schedule)
@@ -91,21 +113,7 @@ struct CronModelsTests {
}
@Test func displayNameTrimsWhitespaceAndFallsBack() {
- let base = CronJob(
- id: "x",
- agentId: nil,
- name: " hello ",
- description: nil,
- enabled: true,
- deleteAfterRun: nil,
- createdAtMs: 0,
- updatedAtMs: 0,
- schedule: .at(at: "2026-02-03T18:00:00Z"),
- sessionTarget: .main,
- wakeMode: .now,
- payload: .systemEvent(text: "hi"),
- delivery: nil,
- state: CronJobState())
+ let base = makeCronJob(name: " hello ", payloadText: "hi")
#expect(base.displayName == "hello")
var unnamed = base
@@ -114,20 +122,9 @@ struct CronModelsTests {
}
@Test func nextRunDateAndLastRunDateDeriveFromState() {
- let job = CronJob(
- id: "x",
- agentId: nil,
+ let job = makeCronJob(
name: "t",
- description: nil,
- enabled: true,
- deleteAfterRun: nil,
- createdAtMs: 0,
- updatedAtMs: 0,
- schedule: .at(at: "2026-02-03T18:00:00Z"),
- sessionTarget: .main,
- wakeMode: .now,
- payload: .systemEvent(text: "hi"),
- delivery: nil,
+ payloadText: "hi",
state: CronJobState(
nextRunAtMs: 1_700_000_000_000,
runningAtMs: nil,
diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift
index b63533177b57..71d979be96f7 100644
--- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift
@@ -51,24 +51,24 @@ struct ExecAllowlistTests {
.appendingPathComponent(filename)
}
- @Test func matchUsesResolvedPath() {
- let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
- let resolution = ExecCommandResolution(
+ private static func homebrewRGResolution() -> ExecCommandResolution {
+ ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
+ }
+
+ @Test func matchUsesResolvedPath() {
+ let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
+ let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchIgnoresBasenamePattern() {
let entry = ExecAllowlistEntry(pattern: "rg")
- let resolution = ExecCommandResolution(
- rawExecutable: "rg",
- resolvedPath: "/opt/homebrew/bin/rg",
- executableName: "rg",
- cwd: nil)
+ let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match == nil)
}
@@ -86,22 +86,14 @@ struct ExecAllowlistTests {
@Test func matchIsCaseInsensitive() {
let entry = ExecAllowlistEntry(pattern: "/OPT/HOMEBREW/BIN/RG")
- let resolution = ExecCommandResolution(
- rawExecutable: "rg",
- resolvedPath: "/opt/homebrew/bin/rg",
- executableName: "rg",
- cwd: nil)
+ let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchSupportsGlobStar() {
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
- let resolution = ExecCommandResolution(
- rawExecutable: "rg",
- resolvedPath: "/opt/homebrew/bin/rg",
- executableName: "rg",
- cwd: nil)
+ let resolution = Self.homebrewRGResolution()
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift
index 9337ee8c947e..42dcf106d1e3 100644
--- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift
@@ -4,13 +4,21 @@ import Testing
@Suite(.serialized)
struct ExecApprovalsStoreRefactorTests {
- @Test
- func ensureFileSkipsRewriteWhenUnchanged() async throws {
+ private func withTempStateDir(
+ _ body: @escaping @Sendable (URL) async throws -> Void) async throws
+ {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager().removeItem(at: stateDir) }
try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
+ try await body(stateDir)
+ }
+ }
+
+ @Test
+ func ensureFileSkipsRewriteWhenUnchanged() async throws {
+ try await self.withTempStateDir { stateDir in
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstWriteDate = try Self.modificationDate(at: url)
@@ -24,12 +32,8 @@ struct ExecApprovalsStoreRefactorTests {
}
@Test
- func updateAllowlistReportsRejectedBasenamePattern() async {
- let stateDir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
- defer { try? FileManager().removeItem(at: stateDir) }
-
- await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
+ func updateAllowlistReportsRejectedBasenamePattern() async throws {
+ try await self.withTempStateDir { _ in
let rejected = ExecApprovalsStore.updateAllowlist(
agentId: "main",
allowlist: [
@@ -46,12 +50,8 @@ struct ExecApprovalsStoreRefactorTests {
}
@Test
- func updateAllowlistMigratesLegacyPatternFromResolvedPath() async {
- let stateDir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
- defer { try? FileManager().removeItem(at: stateDir) }
-
- await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
+ func updateAllowlistMigratesLegacyPatternFromResolvedPath() async throws {
+ try await self.withTempStateDir { _ in
let rejected = ExecApprovalsStore.updateAllowlist(
agentId: "main",
allowlist: [
@@ -70,13 +70,10 @@ struct ExecApprovalsStoreRefactorTests {
@Test
func ensureFileHardensStateDirectoryPermissions() async throws {
- let stateDir = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
- defer { try? FileManager().removeItem(at: stateDir) }
- try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
- try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path)
+ try await self.withTempStateDir { stateDir in
+ try FileManager().createDirectory(at: stateDir, withIntermediateDirectories: true)
+ try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: stateDir.path)
- try await TestIsolation.withEnvValues(["OPENCLAW_STATE_DIR": stateDir.path]) {
_ = ExecApprovalsStore.ensureFile()
let attrs = try FileManager().attributesOfItem(atPath: stateDir.path)
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift
index 4f2fb1a502d2..f1d87fdac5f9 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConfigureTests.swift
@@ -5,118 +5,39 @@ import Testing
@testable import OpenClaw
@Suite struct GatewayConnectionTests {
- private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
- private let connectRequestID = OSAllocatedUnfairLock(initialState: nil)
- private let pendingReceiveHandler =
- OSAllocatedUnfairLock<(@Sendable (Result)
- -> Void)?>(initialState: nil)
- private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
- private let sendCount = OSAllocatedUnfairLock(initialState: 0)
- private let helloDelayMs: Int
-
- var state: URLSessionTask.State = .suspended
-
- init(helloDelayMs: Int = 0) {
- self.helloDelayMs = helloDelayMs
- }
-
- func snapshotCancelCount() -> Int {
- self.cancelCount.withLock { $0 }
- }
-
- func resume() {
- self.state = .running
- }
-
- func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
- _ = (closeCode, reason)
- self.state = .canceling
- self.cancelCount.withLock { $0 += 1 }
- let handler = self.pendingReceiveHandler.withLock { handler in
- defer { handler = nil }
- return handler
- }
- handler?(Result.failure(URLError(.cancelled)))
- }
-
- func send(_ message: URLSessionWebSocketTask.Message) async throws {
- let currentSendCount = self.sendCount.withLock { count in
- defer { count += 1 }
- return count
- }
-
- // First send is the connect handshake request. Subsequent sends are request frames.
- if currentSendCount == 0 {
- if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
- self.connectRequestID.withLock { $0 = id }
- }
- return
- }
-
- guard case let .data(data) = message else { return }
- guard
- let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- (obj["type"] as? String) == "req",
- let id = obj["id"] as? String
- else {
- return
- }
-
- let response = GatewayWebSocketTestSupport.okResponseData(id: id)
- let handler = self.pendingReceiveHandler.withLock { $0 }
- handler?(Result.success(.data(response)))
- }
-
- func receive() async throws -> URLSessionWebSocketTask.Message {
- if self.helloDelayMs > 0 {
- try await Task.sleep(nanoseconds: UInt64(self.helloDelayMs) * 1_000_000)
- }
- let id = self.connectRequestID.withLock { $0 } ?? "connect"
- return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
- }
-
- func receive(
- completionHandler: @escaping @Sendable (Result) -> Void)
- {
- self.pendingReceiveHandler.withLock { $0 = completionHandler }
- }
-
- func emitIncoming(_ data: Data) {
- let handler = self.pendingReceiveHandler.withLock { $0 }
- handler?(Result.success(.data(data)))
- }
+ private func makeConnection(
+ session: GatewayTestWebSocketSession,
+ token: String? = nil) throws -> (GatewayConnection, ConfigSource)
+ {
+ let url = try #require(URL(string: "ws://example.invalid"))
+ let cfg = ConfigSource(token: token)
+ let conn = GatewayConnection(
+ configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
+ sessionBox: WebSocketSessionBox(session: session))
+ return (conn, cfg)
}
- private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
- private let makeCount = OSAllocatedUnfairLock(initialState: 0)
- private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
- private let helloDelayMs: Int
-
- init(helloDelayMs: Int = 0) {
- self.helloDelayMs = helloDelayMs
- }
-
- func snapshotMakeCount() -> Int {
- self.makeCount.withLock { $0 }
- }
-
- func snapshotCancelCount() -> Int {
- self.tasks.withLock { tasks in
- tasks.reduce(0) { $0 + $1.snapshotCancelCount() }
- }
- }
-
- func latestTask() -> FakeWebSocketTask? {
- self.tasks.withLock { $0.last }
- }
-
- func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
- _ = url
- self.makeCount.withLock { $0 += 1 }
- let task = FakeWebSocketTask(helloDelayMs: self.helloDelayMs)
- self.tasks.withLock { $0.append(task) }
- return WebSocketTaskBox(task: task)
- }
+ private func makeSession(helloDelayMs: Int = 0) -> GatewayTestWebSocketSession {
+ GatewayTestWebSocketSession(
+ taskFactory: {
+ GatewayTestWebSocketTask(
+ sendHook: { task, message, sendIndex in
+ guard sendIndex > 0 else { return }
+ guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
+ let response = GatewayWebSocketTestSupport.okResponseData(id: id)
+ task.emitReceiveSuccess(.data(response))
+ },
+ receiveHook: { task, receiveIndex in
+ if receiveIndex == 0 {
+ return .data(GatewayWebSocketTestSupport.connectChallengeData())
+ }
+ if helloDelayMs > 0 {
+ try await Task.sleep(nanoseconds: UInt64(helloDelayMs) * 1_000_000)
+ }
+ let id = task.snapshotConnectRequestID() ?? "connect"
+ return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
+ })
+ })
}
private final class ConfigSource: @unchecked Sendable {
@@ -136,12 +57,8 @@ import Testing
}
@Test func requestReusesSingleWebSocketForSameConfig() async throws {
- let session = FakeWebSocketSession()
- let url = try #require(URL(string: "ws://example.invalid"))
- let cfg = ConfigSource(token: nil)
- let conn = GatewayConnection(
- configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
- sessionBox: WebSocketSessionBox(session: session))
+ let session = self.makeSession()
+ let (conn, _) = try self.makeConnection(session: session)
_ = try await conn.request(method: "status", params: nil)
#expect(session.snapshotMakeCount() == 1)
@@ -152,12 +69,8 @@ import Testing
}
@Test func requestReconfiguresAndCancelsOnTokenChange() async throws {
- let session = FakeWebSocketSession()
- let url = try #require(URL(string: "ws://example.invalid"))
- let cfg = ConfigSource(token: "a")
- let conn = GatewayConnection(
- configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
- sessionBox: WebSocketSessionBox(session: session))
+ let session = self.makeSession()
+ let (conn, cfg) = try self.makeConnection(session: session, token: "a")
_ = try await conn.request(method: "status", params: nil)
#expect(session.snapshotMakeCount() == 1)
@@ -169,12 +82,8 @@ import Testing
}
@Test func concurrentRequestsStillUseSingleWebSocket() async throws {
- let session = FakeWebSocketSession(helloDelayMs: 150)
- let url = try #require(URL(string: "ws://example.invalid"))
- let cfg = ConfigSource(token: nil)
- let conn = GatewayConnection(
- configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
- sessionBox: WebSocketSessionBox(session: session))
+ let session = self.makeSession(helloDelayMs: 150)
+ let (conn, _) = try self.makeConnection(session: session)
async let r1: Data = conn.request(method: "status", params: nil)
async let r2: Data = conn.request(method: "status", params: nil)
@@ -184,12 +93,8 @@ import Testing
}
@Test func subscribeReplaysLatestSnapshot() async throws {
- let session = FakeWebSocketSession()
- let url = try #require(URL(string: "ws://example.invalid"))
- let cfg = ConfigSource(token: nil)
- let conn = GatewayConnection(
- configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
- sessionBox: WebSocketSessionBox(session: session))
+ let session = self.makeSession()
+ let (conn, _) = try self.makeConnection(session: session)
_ = try await conn.request(method: "status", params: nil)
@@ -205,12 +110,8 @@ import Testing
}
@Test func subscribeEmitsSeqGapBeforeEvent() async throws {
- let session = FakeWebSocketSession()
- let url = try #require(URL(string: "ws://example.invalid"))
- let cfg = ConfigSource(token: nil)
- let conn = GatewayConnection(
- configProvider: { (url: url, token: cfg.snapshotToken(), password: nil) },
- sessionBox: WebSocketSessionBox(session: session))
+ let session = self.makeSession()
+ let (conn, _) = try self.makeConnection(session: session)
let stream = await conn.subscribe(bufferingNewest: 10)
var iterator = stream.makeAsyncIterator()
@@ -222,7 +123,7 @@ import Testing
"""
{"type":"event","event":"presence","payload":{"presence":[]},"seq":1}
""".utf8)
- session.latestTask()?.emitIncoming(evt1)
+ session.latestTask()?.emitReceiveSuccess(.data(evt1))
let firstEvent = await iterator.next()
guard case let .event(firstFrame) = firstEvent else {
@@ -235,7 +136,7 @@ import Testing
"""
{"type":"event","event":"presence","payload":{"presence":[]},"seq":3}
""".utf8)
- session.latestTask()?.emitIncoming(evt3)
+ session.latestTask()?.emitReceiveSuccess(.data(evt3))
let gap = await iterator.next()
guard case let .seqGap(expected, received) = gap else {
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift
index 69fc2162e75c..ae0550aa6a76 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelConnectTests.swift
@@ -1,6 +1,5 @@
import Foundation
import OpenClawKit
-import os
import Testing
@testable import OpenClaw
@@ -10,86 +9,33 @@ import Testing
case invalid(delayMs: Int)
}
- private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
- private let response: FakeResponse
- private let connectRequestID = OSAllocatedUnfairLock(initialState: nil)
- private let pendingReceiveHandler =
- OSAllocatedUnfairLock<(@Sendable (Result) -> Void)?>(
- initialState: nil)
-
- var state: URLSessionTask.State = .suspended
-
- init(response: FakeResponse) {
- self.response = response
- }
-
- func resume() {
- self.state = .running
- }
-
- func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
- _ = (closeCode, reason)
- self.state = .canceling
- let handler = self.pendingReceiveHandler.withLock { handler in
- defer { handler = nil }
- return handler
- }
- handler?(Result.failure(URLError(.cancelled)))
- }
-
- func send(_ message: URLSessionWebSocketTask.Message) async throws {
- if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
- self.connectRequestID.withLock { $0 = id }
- }
- }
-
- func receive() async throws -> URLSessionWebSocketTask.Message {
- let delayMs: Int
- let msg: URLSessionWebSocketTask.Message
- switch self.response {
- case let .helloOk(ms):
- delayMs = ms
- let id = self.connectRequestID.withLock { $0 } ?? "connect"
- msg = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
- case let .invalid(ms):
- delayMs = ms
- msg = .string("not json")
- }
- try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
- return msg
- }
-
- func receive(
- completionHandler: @escaping @Sendable (Result) -> Void)
- {
- // The production channel sets up a continuous receive loop after hello.
- // Tests only need the handshake receive; keep the loop idle.
- self.pendingReceiveHandler.withLock { $0 = completionHandler }
- }
- }
-
- private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
- private let response: FakeResponse
- private let makeCount = OSAllocatedUnfairLock(initialState: 0)
-
- init(response: FakeResponse) {
- self.response = response
- }
-
- func snapshotMakeCount() -> Int {
- self.makeCount.withLock { $0 }
- }
-
- func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
- _ = url
- self.makeCount.withLock { $0 += 1 }
- let task = FakeWebSocketTask(response: self.response)
- return WebSocketTaskBox(task: task)
- }
+ private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
+ GatewayTestWebSocketSession(
+ taskFactory: {
+ GatewayTestWebSocketTask(
+ receiveHook: { task, receiveIndex in
+ if receiveIndex == 0 {
+ return .data(GatewayWebSocketTestSupport.connectChallengeData())
+ }
+ let delayMs: Int
+ let message: URLSessionWebSocketTask.Message
+ switch response {
+ case let .helloOk(ms):
+ delayMs = ms
+ let id = task.snapshotConnectRequestID() ?? "connect"
+ message = .data(GatewayWebSocketTestSupport.connectOkData(id: id))
+ case let .invalid(ms):
+ delayMs = ms
+ message = .string("not json")
+ }
+ try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
+ return message
+ })
+ })
}
@Test func concurrentConnectIsSingleFlightOnSuccess() async throws {
- let session = FakeWebSocketSession(response: .helloOk(delayMs: 200))
+ let session = self.makeSession(response: .helloOk(delayMs: 200))
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
@@ -105,7 +51,7 @@ import Testing
}
@Test func concurrentConnectSharesFailure() async throws {
- let session = FakeWebSocketSession(response: .invalid(delayMs: 200))
+ let session = self.makeSession(response: .invalid(delayMs: 200))
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift
index a59d52cc5bfd..95095177300b 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelRequestTests.swift
@@ -1,85 +1,23 @@
import Foundation
import OpenClawKit
-import os
import Testing
@testable import OpenClaw
@Suite struct GatewayChannelRequestTests {
- private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
- private let requestSendDelayMs: Int
- private let connectRequestID = OSAllocatedUnfairLock(initialState: nil)
- private let pendingReceiveHandler =
- OSAllocatedUnfairLock<(@Sendable (Result)
- -> Void)?>(initialState: nil)
- private let sendCount = OSAllocatedUnfairLock(initialState: 0)
-
- var state: URLSessionTask.State = .suspended
-
- init(requestSendDelayMs: Int) {
- self.requestSendDelayMs = requestSendDelayMs
- }
-
- func resume() {
- self.state = .running
- }
-
- func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
- _ = (closeCode, reason)
- self.state = .canceling
- let handler = self.pendingReceiveHandler.withLock { handler in
- defer { handler = nil }
- return handler
- }
- handler?(Result.failure(URLError(.cancelled)))
- }
-
- func send(_ message: URLSessionWebSocketTask.Message) async throws {
- _ = message
- let currentSendCount = self.sendCount.withLock { count in
- defer { count += 1 }
- return count
- }
-
- // First send is the connect handshake. Second send is the request frame.
- if currentSendCount == 0 {
- if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
- self.connectRequestID.withLock { $0 = id }
- }
- }
- if currentSendCount == 1 {
- try await Task.sleep(nanoseconds: UInt64(self.requestSendDelayMs) * 1_000_000)
- throw URLError(.cannotConnectToHost)
- }
- }
-
- func receive() async throws -> URLSessionWebSocketTask.Message {
- let id = self.connectRequestID.withLock { $0 } ?? "connect"
- return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
- }
-
- func receive(
- completionHandler: @escaping @Sendable (Result) -> Void)
- {
- self.pendingReceiveHandler.withLock { $0 = completionHandler }
- }
- }
-
- private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
- private let requestSendDelayMs: Int
-
- init(requestSendDelayMs: Int) {
- self.requestSendDelayMs = requestSendDelayMs
- }
-
- func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
- _ = url
- let task = FakeWebSocketTask(requestSendDelayMs: self.requestSendDelayMs)
- return WebSocketTaskBox(task: task)
- }
+ private func makeSession(requestSendDelayMs: Int) -> GatewayTestWebSocketSession {
+ GatewayTestWebSocketSession(
+ taskFactory: {
+ GatewayTestWebSocketTask(
+ sendHook: { _, _, sendIndex in
+ guard sendIndex == 1 else { return }
+ try await Task.sleep(nanoseconds: UInt64(requestSendDelayMs) * 1_000_000)
+ throw URLError(.cannotConnectToHost)
+ })
+ })
}
@Test func requestTimeoutThenSendFailureDoesNotDoubleResume() async throws {
- let session = FakeWebSocketSession(requestSendDelayMs: 100)
+ let session = self.makeSession(requestSendDelayMs: 100)
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift
index b8239703e32b..ee2d95f3ba47 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayChannelShutdownTests.swift
@@ -1,84 +1,11 @@
import Foundation
import OpenClawKit
-import os
import Testing
@testable import OpenClaw
@Suite struct GatewayChannelShutdownTests {
- private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
- private let connectRequestID = OSAllocatedUnfairLock(initialState: nil)
- private let pendingReceiveHandler =
- OSAllocatedUnfairLock<(@Sendable (Result)
- -> Void)?>(initialState: nil)
- private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
-
- var state: URLSessionTask.State = .suspended
-
- func snapshotCancelCount() -> Int {
- self.cancelCount.withLock { $0 }
- }
-
- func resume() {
- self.state = .running
- }
-
- func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
- _ = (closeCode, reason)
- self.state = .canceling
- self.cancelCount.withLock { $0 += 1 }
- let handler = self.pendingReceiveHandler.withLock { handler in
- defer { handler = nil }
- return handler
- }
- handler?(Result.failure(URLError(.cancelled)))
- }
-
- func send(_ message: URLSessionWebSocketTask.Message) async throws {
- if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
- self.connectRequestID.withLock { $0 = id }
- }
- }
-
- func receive() async throws -> URLSessionWebSocketTask.Message {
- let id = self.connectRequestID.withLock { $0 } ?? "connect"
- return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
- }
-
- func receive(
- completionHandler: @escaping @Sendable (Result) -> Void)
- {
- self.pendingReceiveHandler.withLock { $0 = completionHandler }
- }
-
- func triggerReceiveFailure() {
- let handler = self.pendingReceiveHandler.withLock { $0 }
- handler?(Result.failure(URLError(.networkConnectionLost)))
- }
- }
-
- private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
- private let makeCount = OSAllocatedUnfairLock(initialState: 0)
- private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
-
- func snapshotMakeCount() -> Int {
- self.makeCount.withLock { $0 }
- }
-
- func latestTask() -> FakeWebSocketTask? {
- self.tasks.withLock { $0.last }
- }
-
- func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
- _ = url
- self.makeCount.withLock { $0 += 1 }
- let task = FakeWebSocketTask()
- self.tasks.withLock { $0.append(task) }
- return WebSocketTaskBox(task: task)
- }
- }
-
@Test func shutdownPreventsReconnectLoopFromReceiveFailure() async throws {
- let session = FakeWebSocketSession()
+ let session = GatewayTestWebSocketSession()
let channel = try GatewayChannelActor(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
@@ -89,7 +16,7 @@ import Testing
#expect(session.snapshotMakeCount() == 1)
// Simulate a socket receive failure, which would normally schedule a reconnect.
- session.latestTask()?.triggerReceiveFailure()
+ session.latestTask()?.emitReceiveFailure()
// Shut down quickly, before backoff reconnect triggers.
await channel.shutdown()
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift
index 17ffec07d467..de62fa697873 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayDiscoveryHelpersTests.swift
@@ -27,19 +27,26 @@ struct GatewayDiscoveryHelpersTests {
isLocal: false)
}
- @Test func sshTargetUsesResolvedServiceHostOnly() {
- let gateway = self.makeGateway(
- serviceHost: "resolved.example.ts.net",
- servicePort: 18789,
- sshPort: 2201)
-
+ private func assertSSHTarget(
+ for gateway: GatewayDiscoveryModel.DiscoveredGateway,
+ host: String,
+ port: Int)
+ {
guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
Issue.record("expected ssh target")
return
}
let parsed = CommandResolver.parseSSHTarget(target)
- #expect(parsed?.host == "resolved.example.ts.net")
- #expect(parsed?.port == 2201)
+ #expect(parsed?.host == host)
+ #expect(parsed?.port == port)
+ }
+
+ @Test func sshTargetUsesResolvedServiceHostOnly() {
+ let gateway = self.makeGateway(
+ serviceHost: "resolved.example.ts.net",
+ servicePort: 18789,
+ sshPort: 2201)
+ assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201)
}
@Test func sshTargetAllowsMissingResolvedServicePort() {
@@ -47,14 +54,7 @@ struct GatewayDiscoveryHelpersTests {
serviceHost: "resolved.example.ts.net",
servicePort: nil,
sshPort: 2201)
-
- guard let target = GatewayDiscoveryHelpers.sshTarget(for: gateway) else {
- Issue.record("expected ssh target")
- return
- }
- let parsed = CommandResolver.parseSSHTarget(target)
- #expect(parsed?.host == "resolved.example.ts.net")
- #expect(parsed?.port == 2201)
+ assertSSHTarget(for: gateway, host: "resolved.example.ts.net", port: 2201)
}
@Test func sshTargetRejectsTxtOnlyGateways() {
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
index 4bfd203691a0..3d7796879f6e 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayEndpointStoreTests.swift
@@ -3,6 +3,22 @@ import Testing
@testable import OpenClaw
@Suite struct GatewayEndpointStoreTests {
+ private func makeLaunchAgentSnapshot(
+ env: [String: String],
+ token: String?,
+ password: String?) -> LaunchAgentPlistSnapshot
+ {
+ LaunchAgentPlistSnapshot(
+ programArguments: [],
+ environment: env,
+ stdoutPath: nil,
+ stderrPath: nil,
+ port: nil,
+ bind: nil,
+ token: token,
+ password: password)
+ }
+
private func makeDefaults() -> UserDefaults {
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
@@ -11,13 +27,8 @@ import Testing
}
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
- let snapshot = LaunchAgentPlistSnapshot(
- programArguments: [],
- environment: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"],
- stdoutPath: nil,
- stderrPath: nil,
- port: nil,
- bind: nil,
+ let snapshot = self.makeLaunchAgentSnapshot(
+ env: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"],
token: "launchd-token",
password: nil)
@@ -37,13 +48,8 @@ import Testing
}
@Test func resolveGatewayTokenIgnoresLaunchdInRemoteMode() {
- let snapshot = LaunchAgentPlistSnapshot(
- programArguments: [],
- environment: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"],
- stdoutPath: nil,
- stderrPath: nil,
- port: nil,
- bind: nil,
+ let snapshot = self.makeLaunchAgentSnapshot(
+ env: ["OPENCLAW_GATEWAY_TOKEN": "launchd-token"],
token: "launchd-token",
password: nil)
@@ -56,13 +62,8 @@ import Testing
}
@Test func resolveGatewayPasswordFallsBackToLaunchd() {
- let snapshot = LaunchAgentPlistSnapshot(
- programArguments: [],
- environment: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"],
- stdoutPath: nil,
- stderrPath: nil,
- port: nil,
- bind: nil,
+ let snapshot = self.makeLaunchAgentSnapshot(
+ env: ["OPENCLAW_GATEWAY_PASSWORD": "launchd-pass"],
token: nil,
password: "launchd-pass")
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift
index b510acfd9fed..9ce068817779 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayProcessManagerTests.swift
@@ -1,89 +1,21 @@
import Foundation
import OpenClawKit
-import os
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct GatewayProcessManagerTests {
- private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
- private let connectRequestID = OSAllocatedUnfairLock(initialState: nil)
- private let pendingReceiveHandler =
- OSAllocatedUnfairLock<(@Sendable (Result)
- -> Void)?>(initialState: nil)
- private let cancelCount = OSAllocatedUnfairLock(initialState: 0)
- private let sendCount = OSAllocatedUnfairLock(initialState: 0)
-
- var state: URLSessionTask.State = .suspended
-
- func resume() {
- self.state = .running
- }
-
- func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
- _ = (closeCode, reason)
- self.state = .canceling
- self.cancelCount.withLock { $0 += 1 }
- let handler = self.pendingReceiveHandler.withLock { handler in
- defer { handler = nil }
- return handler
- }
- handler?(Result.failure(URLError(.cancelled)))
- }
-
- func send(_ message: URLSessionWebSocketTask.Message) async throws {
- let currentSendCount = self.sendCount.withLock { count in
- defer { count += 1 }
- return count
- }
-
- if currentSendCount == 0 {
- if let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
- self.connectRequestID.withLock { $0 = id }
- }
- return
- }
-
- guard case let .data(data) = message else { return }
- guard
- let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- (obj["type"] as? String) == "req",
- let id = obj["id"] as? String
- else {
- return
- }
-
- let response = GatewayWebSocketTestSupport.okResponseData(id: id)
- let handler = self.pendingReceiveHandler.withLock { $0 }
- handler?(Result.success(.data(response)))
- }
-
- func receive() async throws -> URLSessionWebSocketTask.Message {
- let id = self.connectRequestID.withLock { $0 } ?? "connect"
- return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
- }
-
- func receive(
- completionHandler: @escaping @Sendable (Result) -> Void)
- {
- self.pendingReceiveHandler.withLock { $0 = completionHandler }
- }
- }
-
- private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
- private let tasks = OSAllocatedUnfairLock(initialState: [FakeWebSocketTask]())
-
- func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
- _ = url
- let task = FakeWebSocketTask()
- self.tasks.withLock { $0.append(task) }
- return WebSocketTaskBox(task: task)
- }
- }
-
@Test func clearsLastFailureWhenHealthSucceeds() async throws {
- let session = FakeWebSocketSession()
+ let session = GatewayTestWebSocketSession(
+ taskFactory: {
+ GatewayTestWebSocketTask(
+ sendHook: { task, message, sendIndex in
+ guard sendIndex > 0 else { return }
+ guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return }
+ task.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id)))
+ })
+ })
let url = try #require(URL(string: "ws://example.invalid"))
let connection = GatewayConnection(
configProvider: { (url: url, token: nil, password: nil) },
diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift
index 56d0387af8af..bb5d7c12d7a9 100644
--- a/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/GatewayWebSocketTestSupport.swift
@@ -9,16 +9,19 @@ extension WebSocketTasking {
}
enum GatewayWebSocketTestSupport {
- static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
- let data: Data? = switch message {
- case let .data(d): d
- case let .string(s): s.data(using: .utf8)
- @unknown default: nil
- }
- guard let data else { return nil }
- guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
- return nil
+ static func connectChallengeData(nonce: String = "test-nonce") -> Data {
+ let json = """
+ {
+ "type": "event",
+ "event": "connect.challenge",
+ "payload": { "nonce": "\(nonce)" }
}
+ """
+ return Data(json.utf8)
+ }
+
+ static func connectRequestID(from message: URLSessionWebSocketTask.Message) -> String? {
+ guard let obj = self.requestFrameObject(from: message) else { return nil }
guard (obj["type"] as? String) == "req", (obj["method"] as? String) == "connect" else {
return nil
}
@@ -49,6 +52,24 @@ enum GatewayWebSocketTestSupport {
return Data(json.utf8)
}
+ static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
+ guard let obj = self.requestFrameObject(from: message) else { return nil }
+ guard (obj["type"] as? String) == "req" else {
+ return nil
+ }
+ return obj["id"] as? String
+ }
+
+ private static func requestFrameObject(from message: URLSessionWebSocketTask.Message) -> [String: Any]? {
+ let data: Data? = switch message {
+ case let .data(d): d
+ case let .string(s): s.data(using: .utf8)
+ @unknown default: nil
+ }
+ guard let data else { return nil }
+ return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ }
+
static func okResponseData(id: String) -> Data {
let json = """
{
@@ -61,3 +82,138 @@ enum GatewayWebSocketTestSupport {
return Data(json.utf8)
}
}
+
+private extension NSLock {
+ @inline(__always)
+ func withLock(_ body: () throws -> T) rethrows -> T {
+ self.lock(); defer { self.unlock() }
+ return try body()
+ }
+}
+
+final class GatewayTestWebSocketTask: WebSocketTasking, @unchecked Sendable {
+ typealias SendHook = @Sendable (GatewayTestWebSocketTask, URLSessionWebSocketTask.Message, Int) async throws -> Void
+ typealias ReceiveHook = @Sendable (GatewayTestWebSocketTask, Int) async throws -> URLSessionWebSocketTask.Message
+
+ private let lock = NSLock()
+ private let sendHook: SendHook?
+ private let receiveHook: ReceiveHook?
+ private var _state: URLSessionTask.State = .suspended
+ private var connectRequestID: String?
+ private var sendCount = 0
+ private var receiveCount = 0
+ private var cancelCount = 0
+ private var pendingReceiveHandler: (@Sendable (Result) -> Void)?
+
+ init(sendHook: SendHook? = nil, receiveHook: ReceiveHook? = nil) {
+ self.sendHook = sendHook
+ self.receiveHook = receiveHook
+ }
+
+ var state: URLSessionTask.State {
+ get { self.lock.withLock { self._state } }
+ set { self.lock.withLock { self._state = newValue } }
+ }
+
+ func snapshotCancelCount() -> Int {
+ self.lock.withLock { self.cancelCount }
+ }
+
+ func snapshotConnectRequestID() -> String? {
+ self.lock.withLock { self.connectRequestID }
+ }
+
+ func resume() {
+ self.state = .running
+ }
+
+ func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
+ _ = (closeCode, reason)
+ let handler = self.lock.withLock { () -> (@Sendable (Result) -> Void)? in
+ self._state = .canceling
+ self.cancelCount += 1
+ defer { self.pendingReceiveHandler = nil }
+ return self.pendingReceiveHandler
+ }
+ handler?(Result.failure(URLError(.cancelled)))
+ }
+
+ func send(_ message: URLSessionWebSocketTask.Message) async throws {
+ let sendIndex = self.lock.withLock { () -> Int in
+ let current = self.sendCount
+ self.sendCount += 1
+ return current
+ }
+ if sendIndex == 0, let id = GatewayWebSocketTestSupport.connectRequestID(from: message) {
+ self.lock.withLock { self.connectRequestID = id }
+ }
+ try await self.sendHook?(self, message, sendIndex)
+ }
+
+ func receive() async throws -> URLSessionWebSocketTask.Message {
+ let receiveIndex = self.lock.withLock { () -> Int in
+ let current = self.receiveCount
+ self.receiveCount += 1
+ return current
+ }
+ if let receiveHook = self.receiveHook {
+ return try await receiveHook(self, receiveIndex)
+ }
+ if receiveIndex == 0 {
+ return .data(GatewayWebSocketTestSupport.connectChallengeData())
+ }
+ let id = self.snapshotConnectRequestID() ?? "connect"
+ return .data(GatewayWebSocketTestSupport.connectOkData(id: id))
+ }
+
+ func receive(
+ completionHandler: @escaping @Sendable (Result) -> Void)
+ {
+ self.lock.withLock { self.pendingReceiveHandler = completionHandler }
+ }
+
+ func emitReceiveSuccess(_ message: URLSessionWebSocketTask.Message) {
+ let handler = self.lock.withLock { self.pendingReceiveHandler }
+ handler?(Result.success(message))
+ }
+
+ func emitReceiveFailure(_ error: Error = URLError(.networkConnectionLost)) {
+ let handler = self.lock.withLock { self.pendingReceiveHandler }
+ handler?(Result.failure(error))
+ }
+}
+
+final class GatewayTestWebSocketSession: WebSocketSessioning, @unchecked Sendable {
+ typealias TaskFactory = @Sendable () -> GatewayTestWebSocketTask
+
+ private let lock = NSLock()
+ private let taskFactory: TaskFactory
+ private var tasks: [GatewayTestWebSocketTask] = []
+ private var makeCount = 0
+
+ init(taskFactory: @escaping TaskFactory = { GatewayTestWebSocketTask() }) {
+ self.taskFactory = taskFactory
+ }
+
+ func snapshotMakeCount() -> Int {
+ self.lock.withLock { self.makeCount }
+ }
+
+ func snapshotCancelCount() -> Int {
+ self.lock.withLock { self.tasks.reduce(0) { $0 + $1.snapshotCancelCount() } }
+ }
+
+ func latestTask() -> GatewayTestWebSocketTask? {
+ self.lock.withLock { self.tasks.last }
+ }
+
+ func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
+ _ = url
+ let task = self.taskFactory()
+ self.lock.withLock {
+ self.makeCount += 1
+ self.tasks.append(task)
+ }
+ return WebSocketTaskBox(task: task)
+ }
+}
diff --git a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift
index 9ee41b4f7b98..7f2a53d43b7c 100644
--- a/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/NodeManagerPathsTests.swift
@@ -3,30 +3,15 @@ import Testing
@testable import OpenClaw
@Suite struct NodeManagerPathsTests {
- private func makeTempDir() throws -> URL {
- let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
- let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
- try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
- return dir
- }
-
- private func makeExec(at path: URL) throws {
- try FileManager().createDirectory(
- at: path.deletingLastPathComponent(),
- withIntermediateDirectories: true)
- FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
- try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
- }
-
@Test func fnmNodeBinsPreferNewestInstalledVersion() throws {
- let home = try self.makeTempDir()
+ let home = try makeTempDirForTests()
let v20Bin = home
.appendingPathComponent(".local/share/fnm/node-versions/v20.19.5/installation/bin/node")
let v25Bin = home
.appendingPathComponent(".local/share/fnm/node-versions/v25.1.0/installation/bin/node")
- try self.makeExec(at: v20Bin)
- try self.makeExec(at: v25Bin)
+ try makeExecutableForTests(at: v20Bin)
+ try makeExecutableForTests(at: v25Bin)
let bins = CommandResolver._testNodeManagerBinPaths(home: home)
#expect(bins.first == v25Bin.deletingLastPathComponent().path)
@@ -34,7 +19,7 @@ import Testing
}
@Test func ignoresEntriesWithoutNodeExecutable() throws {
- let home = try self.makeTempDir()
+ let home = try makeTempDirForTests()
let missingNodeBin = home
.appendingPathComponent(".local/share/fnm/node-versions/v99.0.0/installation/bin")
try FileManager().createDirectory(at: missingNodeBin, withIntermediateDirectories: true)
diff --git a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
index 2cd9d6432e21..7c3804eb494b 100644
--- a/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/OpenClawConfigFileTests.swift
@@ -4,12 +4,16 @@ import Testing
@Suite(.serialized)
struct OpenClawConfigFileTests {
- @Test
- func configPathRespectsEnvOverride() async {
- let override = FileManager().temporaryDirectory
+ private func makeConfigOverridePath() -> String {
+ FileManager().temporaryDirectory
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
.appendingPathComponent("openclaw.json")
.path
+ }
+
+ @Test
+ func configPathRespectsEnvOverride() async {
+ let override = makeConfigOverridePath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
#expect(OpenClawConfigFile.url().path == override)
@@ -19,10 +23,7 @@ struct OpenClawConfigFileTests {
@MainActor
@Test
func remoteGatewayPortParsesAndMatchesHost() async {
- let override = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-config-\(UUID().uuidString)")
- .appendingPathComponent("openclaw.json")
- .path
+ let override = makeConfigOverridePath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
OpenClawConfigFile.saveDict([
@@ -42,10 +43,7 @@ struct OpenClawConfigFileTests {
@MainActor
@Test
func setRemoteGatewayUrlPreservesScheme() async {
- let override = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-config-\(UUID().uuidString)")
- .appendingPathComponent("openclaw.json")
- .path
+ let override = makeConfigOverridePath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
OpenClawConfigFile.saveDict([
@@ -65,10 +63,7 @@ struct OpenClawConfigFileTests {
@MainActor
@Test
func clearRemoteGatewayUrlRemovesOnlyUrlField() async {
- let override = FileManager().temporaryDirectory
- .appendingPathComponent("openclaw-config-\(UUID().uuidString)")
- .appendingPathComponent("openclaw.json")
- .path
+ let override = makeConfigOverridePath()
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
OpenClawConfigFile.saveDict([
diff --git a/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift
index 560f3d2f50bf..ad2ae573ca29 100644
--- a/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/SkillsSettingsSmokeTests.swift
@@ -2,6 +2,42 @@ import OpenClawProtocol
import Testing
@testable import OpenClaw
+private func makeSkillStatus(
+ name: String,
+ description: String,
+ source: String,
+ filePath: String,
+ skillKey: String,
+ primaryEnv: String? = nil,
+ emoji: String,
+ homepage: String? = nil,
+ disabled: Bool = false,
+ eligible: Bool,
+ requirements: SkillRequirements = SkillRequirements(bins: [], env: [], config: []),
+ missing: SkillMissing = SkillMissing(bins: [], env: [], config: []),
+ configChecks: [SkillStatusConfigCheck] = [],
+ install: [SkillInstallOption] = [])
+ -> SkillStatus
+{
+ SkillStatus(
+ name: name,
+ description: description,
+ source: source,
+ filePath: filePath,
+ baseDir: "/tmp/skills",
+ skillKey: skillKey,
+ primaryEnv: primaryEnv,
+ emoji: emoji,
+ homepage: homepage,
+ always: false,
+ disabled: disabled,
+ eligible: eligible,
+ requirements: requirements,
+ missing: missing,
+ configChecks: configChecks,
+ install: install)
+}
+
@Suite(.serialized)
@MainActor
struct SkillsSettingsSmokeTests {
@@ -9,18 +45,15 @@ struct SkillsSettingsSmokeTests {
let model = SkillsSettingsModel()
model.statusMessage = "Loaded"
model.skills = [
- SkillStatus(
+ makeSkillStatus(
name: "Needs Setup",
description: "Missing bins and env",
source: "openclaw-managed",
filePath: "/tmp/skills/needs-setup",
- baseDir: "/tmp/skills",
skillKey: "needs-setup",
primaryEnv: "API_KEY",
emoji: "🧰",
homepage: "https://example.com/needs-setup",
- always: false,
- disabled: false,
eligible: false,
requirements: SkillRequirements(
bins: ["python3"],
@@ -36,43 +69,29 @@ struct SkillsSettingsSmokeTests {
install: [
SkillInstallOption(id: "brew", kind: "brew", label: "brew install python", bins: ["python3"]),
]),
- SkillStatus(
+ makeSkillStatus(
name: "Ready Skill",
description: "All set",
source: "openclaw-bundled",
filePath: "/tmp/skills/ready",
- baseDir: "/tmp/skills",
skillKey: "ready",
- primaryEnv: nil,
emoji: "✅",
homepage: "https://example.com/ready",
- always: false,
- disabled: false,
eligible: true,
- requirements: SkillRequirements(bins: [], env: [], config: []),
- missing: SkillMissing(bins: [], env: [], config: []),
configChecks: [
SkillStatusConfigCheck(path: "skills.ready", value: AnyCodable(true), satisfied: true),
SkillStatusConfigCheck(path: "skills.limit", value: AnyCodable(5), satisfied: true),
],
install: []),
- SkillStatus(
+ makeSkillStatus(
name: "Disabled Skill",
description: "Disabled in config",
source: "openclaw-extra",
filePath: "/tmp/skills/disabled",
- baseDir: "/tmp/skills",
skillKey: "disabled",
- primaryEnv: nil,
emoji: "🚫",
- homepage: nil,
- always: false,
disabled: true,
- eligible: false,
- requirements: SkillRequirements(bins: [], env: [], config: []),
- missing: SkillMissing(bins: [], env: [], config: []),
- configChecks: [],
- install: []),
+ eligible: false),
]
let state = AppState(preview: true)
@@ -87,23 +106,14 @@ struct SkillsSettingsSmokeTests {
@Test func skillsSettingsBuildsBodyWithLocalMode() {
let model = SkillsSettingsModel()
model.skills = [
- SkillStatus(
+ makeSkillStatus(
name: "Local Skill",
description: "Local ready",
source: "openclaw-workspace",
filePath: "/tmp/skills/local",
- baseDir: "/tmp/skills",
skillKey: "local",
- primaryEnv: nil,
emoji: "🏠",
- homepage: nil,
- always: false,
- disabled: false,
- eligible: true,
- requirements: SkillRequirements(bins: [], env: [], config: []),
- missing: SkillMissing(bins: [], env: [], config: []),
- configChecks: [],
- install: []),
+ eligible: true),
]
let state = AppState(preview: true)
diff --git a/apps/macos/Tests/OpenClawIPCTests/TestFSHelpers.swift b/apps/macos/Tests/OpenClawIPCTests/TestFSHelpers.swift
new file mode 100644
index 000000000000..1f5bab997b4c
--- /dev/null
+++ b/apps/macos/Tests/OpenClawIPCTests/TestFSHelpers.swift
@@ -0,0 +1,16 @@
+import Foundation
+
+func makeTempDirForTests() throws -> URL {
+ let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
+ let dir = base.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
+ return dir
+}
+
+func makeExecutableForTests(at path: URL) throws {
+ try FileManager().createDirectory(
+ at: path.deletingLastPathComponent(),
+ withIntermediateDirectories: true)
+ FileManager().createFile(atPath: path.path, contents: Data("echo ok\n".utf8))
+ try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: path.path)
+}
diff --git a/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift b/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift
index 1002b7ed3073..8be68afed24b 100644
--- a/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/TestIsolation.swift
@@ -34,6 +34,26 @@ enum TestIsolation {
defaults: [String: Any?] = [:],
_ body: () async throws -> T) async rethrows -> T
{
+ func restoreUserDefaults(_ values: [String: Any?], userDefaults: UserDefaults) {
+ for (key, value) in values {
+ if let value {
+ userDefaults.set(value, forKey: key)
+ } else {
+ userDefaults.removeObject(forKey: key)
+ }
+ }
+ }
+
+ func restoreEnv(_ values: [String: String?]) {
+ for (key, value) in values {
+ if let value {
+ setenv(key, value, 1)
+ } else {
+ unsetenv(key)
+ }
+ }
+ }
+
await TestIsolationLock.shared.acquire()
var previousEnv: [String: String?] = [:]
for (key, value) in env {
@@ -58,37 +78,13 @@ enum TestIsolation {
do {
let result = try await body()
- for (key, value) in previousDefaults {
- if let value {
- userDefaults.set(value, forKey: key)
- } else {
- userDefaults.removeObject(forKey: key)
- }
- }
- for (key, value) in previousEnv {
- if let value {
- setenv(key, value, 1)
- } else {
- unsetenv(key)
- }
- }
+ restoreUserDefaults(previousDefaults, userDefaults: userDefaults)
+ restoreEnv(previousEnv)
await TestIsolationLock.shared.release()
return result
} catch {
- for (key, value) in previousDefaults {
- if let value {
- userDefaults.set(value, forKey: key)
- } else {
- userDefaults.removeObject(forKey: key)
- }
- }
- for (key, value) in previousEnv {
- if let value {
- setenv(key, value, 1)
- } else {
- unsetenv(key)
- }
- }
+ restoreUserDefaults(previousDefaults, userDefaults: userDefaults)
+ restoreEnv(previousEnv)
await TestIsolationLock.shared.release()
throw error
}
diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift
index 1d95bb470506..d19a9ccc25f9 100644
--- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeGlobalSettingsSyncTests.swift
@@ -4,20 +4,26 @@ import Testing
@testable import OpenClaw
@Suite(.serialized) struct VoiceWakeGlobalSettingsSyncTests {
- @Test func appliesVoiceWakeChangedEventToAppState() async {
- let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
-
- await MainActor.run {
- AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"])
- }
-
- let payload = OpenClawProtocol.AnyCodable(["triggers": ["openclaw", "computer"]])
- let evt = EventFrame(
+ private func voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable) -> EventFrame {
+ EventFrame(
type: "event",
event: "voicewake.changed",
payload: payload,
seq: nil,
stateversion: nil)
+ }
+
+ private func applyTriggersAndCapturePrevious(_ triggers: [String]) async -> [String] {
+ let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
+ await MainActor.run {
+ AppStateStore.shared.applyGlobalVoiceWakeTriggers(triggers)
+ }
+ return previous
+ }
+
+ @Test func appliesVoiceWakeChangedEventToAppState() async {
+ let previous = await applyTriggersAndCapturePrevious(["before"])
+ let evt = voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable(["triggers": ["openclaw", "computer"]]))
await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt))
@@ -30,19 +36,8 @@ import Testing
}
@Test func ignoresVoiceWakeChangedEventWithInvalidPayload() async {
- let previous = await MainActor.run { AppStateStore.shared.swabbleTriggerWords }
-
- await MainActor.run {
- AppStateStore.shared.applyGlobalVoiceWakeTriggers(["before"])
- }
-
- let payload = OpenClawProtocol.AnyCodable(["unexpected": 123])
- let evt = EventFrame(
- type: "event",
- event: "voicewake.changed",
- payload: payload,
- seq: nil,
- stateversion: nil)
+ let previous = await applyTriggersAndCapturePrevious(["before"])
+ let evt = voiceWakeChangedEvent(payload: OpenClawProtocol.AnyCodable(["unexpected": 123]))
await VoiceWakeGlobalSettingsSync.shared.handle(push: .event(evt))
diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift
index 89345914df61..684aec74d4c8 100644
--- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift
@@ -49,7 +49,7 @@ import Testing
@Test func gateRequiresGapBetweenTriggerAndCommand() {
let transcript = "hey openclaw do thing"
- let segments = makeSegments(
+ let segments = makeWakeWordSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
@@ -63,7 +63,7 @@ import Testing
@Test func gateAcceptsGapAndExtractsCommand() {
let transcript = "hey openclaw do thing"
- let segments = makeSegments(
+ let segments = makeWakeWordSegments(
transcript: transcript,
words: [
("hey", 0.0, 0.1),
@@ -75,17 +75,3 @@ import Testing
#expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing")
}
}
-
-private func makeSegments(
- transcript: String,
- words: [(String, TimeInterval, TimeInterval)])
--> [WakeWordSegment] {
- var searchStart = transcript.startIndex
- var output: [WakeWordSegment] = []
- for (word, start, duration) in words {
- let range = transcript.range(of: word, range: searchStart.. [WakeWordSegment] {
+ var cursor = transcript.startIndex
+ return words.map { word, start, duration in
+ let range = transcript.range(of: word, range: cursor.. [WakeWordSegment] {
- var searchStart = transcript.startIndex
- var output: [WakeWordSegment] = []
- for (word, start, duration) in words {
- let range = transcript.range(of: word, range: searchStart.. some View {
+ self
+ .background(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .fill(OpenClawChatTheme.assistantBubble))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
+ .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
+ .focusable(false)
+ }
+}
+
@MainActor
struct ChatStreamingAssistantBubble: View {
let text: String
@@ -498,14 +516,7 @@ struct ChatStreamingAssistantBubble: View {
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
}
.padding(12)
- .background(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(OpenClawChatTheme.assistantBubble))
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
- .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
- .focusable(false)
+ .assistantBubbleContainerStyle()
}
}
@@ -542,14 +553,7 @@ struct ChatPendingToolsBubble: View {
}
}
.padding(12)
- .background(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .fill(OpenClawChatTheme.assistantBubble))
- .overlay(
- RoundedRectangle(cornerRadius: 16, style: .continuous)
- .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
- .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
- .focusable(false)
+ .assistantBubbleContainerStyle()
}
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift
new file mode 100644
index 000000000000..604b21ae47f9
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/BonjourServiceResolverSupport.swift
@@ -0,0 +1,14 @@
+import Foundation
+
+public enum BonjourServiceResolverSupport {
+ public static func start(_ service: NetService, timeout: TimeInterval = 2.0) {
+ service.schedule(in: .main, forMode: .common)
+ service.resolve(withTimeout: timeout)
+ }
+
+ public static func normalizeHost(_ raw: String?) -> String? {
+ let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ guard !trimmed.isEmpty else { return nil }
+ return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift
index 9935b81ba924..c2b4202d539f 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CalendarCommands.swift
@@ -5,17 +5,7 @@ public enum OpenClawCalendarCommand: String, Codable, Sendable {
case add = "calendar.add"
}
-public struct OpenClawCalendarEventsParams: Codable, Sendable, Equatable {
- public var startISO: String?
- public var endISO: String?
- public var limit: Int?
-
- public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
- self.startISO = startISO
- self.endISO = endISO
- self.limit = limit
- }
-}
+public typealias OpenClawCalendarEventsParams = OpenClawDateRangeLimitParams
public struct OpenClawCalendarAddParams: Codable, Sendable, Equatable {
public var title: String
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift
new file mode 100644
index 000000000000..c7c1182eca37
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraAuthorization.swift
@@ -0,0 +1,21 @@
+import AVFoundation
+
+public enum CameraAuthorization {
+ public static func isAuthorized(for mediaType: AVMediaType) async -> Bool {
+ let status = AVCaptureDevice.authorizationStatus(for: mediaType)
+ switch status {
+ case .authorized:
+ return true
+ case .notDetermined:
+ return await withCheckedContinuation(isolation: nil) { cont in
+ AVCaptureDevice.requestAccess(for: mediaType) { granted in
+ cont.resume(returning: granted)
+ }
+ }
+ case .denied, .restricted:
+ return false
+ @unknown default:
+ return false
+ }
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift
new file mode 100644
index 000000000000..075761a76b3c
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraCapturePipelineSupport.swift
@@ -0,0 +1,151 @@
+import AVFoundation
+import Foundation
+
+public enum CameraCapturePipelineSupport {
+ public static func preparePhotoSession(
+ preferFrontCamera: Bool,
+ deviceId: String?,
+ pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
+ cameraUnavailableError: @autoclosure () -> Error,
+ mapSetupError: (CameraSessionConfigurationError) -> Error) throws
+ -> (session: AVCaptureSession, device: AVCaptureDevice, output: AVCapturePhotoOutput)
+ {
+ let session = AVCaptureSession()
+ session.sessionPreset = .photo
+
+ guard let device = pickCamera(preferFrontCamera, deviceId) else {
+ throw cameraUnavailableError()
+ }
+
+ do {
+ try CameraSessionConfiguration.addCameraInput(session: session, camera: device)
+ let output = try CameraSessionConfiguration.addPhotoOutput(session: session)
+ return (session, device, output)
+ } catch let setupError as CameraSessionConfigurationError {
+ throw mapSetupError(setupError)
+ }
+ }
+
+ public static func prepareMovieSession(
+ preferFrontCamera: Bool,
+ deviceId: String?,
+ includeAudio: Bool,
+ durationMs: Int,
+ pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
+ cameraUnavailableError: @autoclosure () -> Error,
+ mapSetupError: (CameraSessionConfigurationError) -> Error) throws
+ -> (session: AVCaptureSession, output: AVCaptureMovieFileOutput)
+ {
+ let session = AVCaptureSession()
+ session.sessionPreset = .high
+
+ guard let camera = pickCamera(preferFrontCamera, deviceId) else {
+ throw cameraUnavailableError()
+ }
+
+ do {
+ try CameraSessionConfiguration.addCameraInput(session: session, camera: camera)
+ let output = try CameraSessionConfiguration.addMovieOutput(
+ session: session,
+ includeAudio: includeAudio,
+ durationMs: durationMs)
+ return (session, output)
+ } catch let setupError as CameraSessionConfigurationError {
+ throw mapSetupError(setupError)
+ }
+ }
+
+ public static func prepareWarmMovieSession(
+ preferFrontCamera: Bool,
+ deviceId: String?,
+ includeAudio: Bool,
+ durationMs: Int,
+ pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
+ cameraUnavailableError: @autoclosure () -> Error,
+ mapSetupError: (CameraSessionConfigurationError) -> Error) async throws
+ -> (session: AVCaptureSession, output: AVCaptureMovieFileOutput)
+ {
+ let prepared = try self.prepareMovieSession(
+ preferFrontCamera: preferFrontCamera,
+ deviceId: deviceId,
+ includeAudio: includeAudio,
+ durationMs: durationMs,
+ pickCamera: pickCamera,
+ cameraUnavailableError: cameraUnavailableError(),
+ mapSetupError: mapSetupError)
+ prepared.session.startRunning()
+ await self.warmUpCaptureSession()
+ return prepared
+ }
+
+ public static func withWarmMovieSession(
+ preferFrontCamera: Bool,
+ deviceId: String?,
+ includeAudio: Bool,
+ durationMs: Int,
+ pickCamera: (_ preferFrontCamera: Bool, _ deviceId: String?) -> AVCaptureDevice?,
+ cameraUnavailableError: @autoclosure () -> Error,
+ mapSetupError: (CameraSessionConfigurationError) -> Error,
+ operation: (AVCaptureMovieFileOutput) async throws -> T) async throws -> T
+ {
+ let prepared = try await self.prepareWarmMovieSession(
+ preferFrontCamera: preferFrontCamera,
+ deviceId: deviceId,
+ includeAudio: includeAudio,
+ durationMs: durationMs,
+ pickCamera: pickCamera,
+ cameraUnavailableError: cameraUnavailableError(),
+ mapSetupError: mapSetupError)
+ defer { prepared.session.stopRunning() }
+ return try await operation(prepared.output)
+ }
+
+ public static func mapMovieSetupError(
+ _ setupError: CameraSessionConfigurationError,
+ microphoneUnavailableError: @autoclosure () -> E,
+ captureFailed: (String) -> E) -> E
+ {
+ if case .microphoneUnavailable = setupError {
+ return microphoneUnavailableError()
+ }
+ return captureFailed(setupError.localizedDescription)
+ }
+
+ public static func makePhotoSettings(output: AVCapturePhotoOutput) -> AVCapturePhotoSettings {
+ let settings: AVCapturePhotoSettings = {
+ if output.availablePhotoCodecTypes.contains(.jpeg) {
+ return AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])
+ }
+ return AVCapturePhotoSettings()
+ }()
+ settings.photoQualityPrioritization = .quality
+ return settings
+ }
+
+ public static func capturePhotoData(
+ output: AVCapturePhotoOutput,
+ makeDelegate: (CheckedContinuation) -> any AVCapturePhotoCaptureDelegate) async throws -> Data
+ {
+ var delegate: (any AVCapturePhotoCaptureDelegate)?
+ let rawData: Data = try await withCheckedThrowingContinuation { cont in
+ let captureDelegate = makeDelegate(cont)
+ delegate = captureDelegate
+ output.capturePhoto(with: self.makePhotoSettings(output: output), delegate: captureDelegate)
+ }
+ withExtendedLifetime(delegate) {}
+ return rawData
+ }
+
+ public static func warmUpCaptureSession() async {
+ // A short delay after `startRunning()` significantly reduces "blank first frame" captures on some devices.
+ try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
+ }
+
+ public static func positionLabel(_ position: AVCaptureDevice.Position) -> String {
+ switch position {
+ case .front: "front"
+ case .back: "back"
+ default: "unspecified"
+ }
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift
new file mode 100644
index 000000000000..748315ebc022
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CameraSessionConfiguration.swift
@@ -0,0 +1,70 @@
+import AVFoundation
+import CoreMedia
+
+public enum CameraSessionConfigurationError: LocalizedError {
+ case addCameraInputFailed
+ case addPhotoOutputFailed
+ case microphoneUnavailable
+ case addMicrophoneInputFailed
+ case addMovieOutputFailed
+
+ public var errorDescription: String? {
+ switch self {
+ case .addCameraInputFailed:
+ "Failed to add camera input"
+ case .addPhotoOutputFailed:
+ "Failed to add photo output"
+ case .microphoneUnavailable:
+ "Microphone unavailable"
+ case .addMicrophoneInputFailed:
+ "Failed to add microphone input"
+ case .addMovieOutputFailed:
+ "Failed to add movie output"
+ }
+ }
+}
+
+public enum CameraSessionConfiguration {
+ public static func addCameraInput(session: AVCaptureSession, camera: AVCaptureDevice) throws {
+ let input = try AVCaptureDeviceInput(device: camera)
+ guard session.canAddInput(input) else {
+ throw CameraSessionConfigurationError.addCameraInputFailed
+ }
+ session.addInput(input)
+ }
+
+ public static func addPhotoOutput(session: AVCaptureSession) throws -> AVCapturePhotoOutput {
+ let output = AVCapturePhotoOutput()
+ guard session.canAddOutput(output) else {
+ throw CameraSessionConfigurationError.addPhotoOutputFailed
+ }
+ session.addOutput(output)
+ output.maxPhotoQualityPrioritization = .quality
+ return output
+ }
+
+ public static func addMovieOutput(
+ session: AVCaptureSession,
+ includeAudio: Bool,
+ durationMs: Int) throws -> AVCaptureMovieFileOutput
+ {
+ if includeAudio {
+ guard let mic = AVCaptureDevice.default(for: .audio) else {
+ throw CameraSessionConfigurationError.microphoneUnavailable
+ }
+ let micInput = try AVCaptureDeviceInput(device: mic)
+ guard session.canAddInput(micInput) else {
+ throw CameraSessionConfigurationError.addMicrophoneInputFailed
+ }
+ session.addInput(micInput)
+ }
+
+ let output = AVCaptureMovieFileOutput()
+ guard session.canAddOutput(output) else {
+ throw CameraSessionConfigurationError.addMovieOutputFailed
+ }
+ session.addOutput(output)
+ output.maxRecordedDuration = CMTime(value: Int64(durationMs), timescale: 1000)
+ return output
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift
new file mode 100644
index 000000000000..5b95bf6bf046
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/CaptureRateLimits.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+public enum CaptureRateLimits {
+ public static func clampDurationMs(
+ _ ms: Int?,
+ defaultMs: Int = 10_000,
+ minMs: Int = 250,
+ maxMs: Int = 60_000) -> Int
+ {
+ let value = ms ?? defaultMs
+ return min(maxMs, max(minMs, value))
+ }
+
+ public static func clampFps(
+ _ fps: Double?,
+ defaultFps: Double = 10,
+ minFps: Double = 1,
+ maxFps: Double) -> Double
+ {
+ let value = fps ?? defaultFps
+ guard value.isFinite else { return defaultFps }
+ return min(maxFps, max(minFps, value))
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
index 507148846199..20b3761668b7 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift
@@ -1,5 +1,4 @@
import Foundation
-import Network
public enum DeepLinkRoute: Sendable, Equatable {
case agent(AgentDeepLink)
@@ -21,40 +20,6 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
self.password = password
}
- fileprivate static func isLoopbackHost(_ raw: String) -> Bool {
- var host = raw
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased()
- .trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
- if host.hasSuffix(".") {
- host.removeLast()
- }
- if let zoneIndex = host.firstIndex(of: "%") {
- host = String(host[.. [String: OpenClawProtocol.AnyCodable]?
+ {
+ guard let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
+ let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
+ else {
+ return nil
+ }
+ return [
+ "id": OpenClawProtocol.AnyCodable(identity.deviceId),
+ "publicKey": OpenClawProtocol.AnyCodable(publicKey),
+ "signature": OpenClawProtocol.AnyCodable(signature),
+ "signedAt": OpenClawProtocol.AnyCodable(signedAtMs),
+ "nonce": OpenClawProtocol.AnyCodable(nonce),
+ ]
+ }
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
index e8a53412cd10..3dc5eacee6eb 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift
@@ -45,11 +45,7 @@ public struct WebSocketTaskBox: @unchecked Sendable {
public func sendPing() async throws {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
self.task.sendPing { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume(returning: ())
- }
+ ThrowingContinuationSupport.resumeVoid(continuation, error: error)
}
}
}
@@ -410,15 +406,12 @@ public actor GatewayChannelActor {
nonce: connectNonce,
platform: platform,
deviceFamily: InstanceIdentity.deviceFamily)
- if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
- let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity) {
- let device: [String: ProtoAnyCodable] = [
- "id": ProtoAnyCodable(identity.deviceId),
- "publicKey": ProtoAnyCodable(publicKey),
- "signature": ProtoAnyCodable(signature),
- "signedAt": ProtoAnyCodable(signedAtMs),
- "nonce": ProtoAnyCodable(connectNonce),
- ]
+ if let device = GatewayDeviceAuthPayload.signedDeviceDictionary(
+ payload: payload,
+ identity: identity,
+ signedAtMs: signedAtMs,
+ nonce: connectNonce)
+ {
params["device"] = ProtoAnyCodable(device)
}
}
@@ -560,8 +553,7 @@ public actor GatewayChannelActor {
guard let frame = try? self.decoder.decode(GatewayFrame.self, from: data) else { continue }
if case let .event(evt) = frame, evt.event == "connect.challenge",
let payload = evt.payload?.value as? [String: ProtoAnyCodable],
- let nonce = payload["nonce"]?.value as? String,
- nonce.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
+ let nonce = GatewayConnectChallengeSupport.nonce(from: payload)
{
return nonce
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift
new file mode 100644
index 000000000000..f2ad187bc463
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayConnectChallengeSupport.swift
@@ -0,0 +1,28 @@
+import Foundation
+import OpenClawProtocol
+
+public enum GatewayConnectChallengeSupport {
+ public static func nonce(from payload: [String: OpenClawProtocol.AnyCodable]?) -> String? {
+ guard let nonce = payload?["nonce"]?.value as? String else { return nil }
+ let trimmed = nonce.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return nil }
+ return trimmed
+ }
+
+ public static func waitForNonce(
+ timeoutSeconds: Double,
+ onTimeout: @escaping @Sendable () -> E,
+ receiveNonce: @escaping @Sendable () async throws -> String?) async throws -> String
+ {
+ try await AsyncTimeout.withTimeout(
+ seconds: timeoutSeconds,
+ onTimeout: onTimeout,
+ operation: {
+ while true {
+ if let nonce = try await receiveNonce() {
+ return nonce
+ }
+ }
+ })
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift
new file mode 100644
index 000000000000..4f477b92a8d2
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryBrowserSupport.swift
@@ -0,0 +1,32 @@
+import Foundation
+import Network
+
+public enum GatewayDiscoveryBrowserSupport {
+ @MainActor
+ public static func makeBrowser(
+ serviceType: String,
+ domain: String,
+ queueLabelPrefix: String,
+ onState: @escaping @MainActor (NWBrowser.State) -> Void,
+ onResults: @escaping @MainActor (Set) -> Void) -> NWBrowser
+ {
+ let params = NWParameters.tcp
+ params.includePeerToPeer = true
+ let browser = NWBrowser(
+ for: .bonjour(type: serviceType, domain: domain),
+ using: params)
+
+ browser.stateUpdateHandler = { state in
+ Task { @MainActor in
+ onState(state)
+ }
+ }
+ browser.browseResultsChangedHandler = { results, _ in
+ Task { @MainActor in
+ onResults(results)
+ }
+ }
+ browser.start(queue: DispatchQueue(label: "\(queueLabelPrefix).\(domain)"))
+ return browser
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
index 7dd2fe1eee1b..a3c09ff35040 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift
@@ -293,13 +293,7 @@ public actor GatewayNodeSession {
private func resetConnectionState() {
self.hasNotifiedConnected = false
self.snapshotReceived = false
- if !self.snapshotWaiters.isEmpty {
- let waiters = self.snapshotWaiters
- self.snapshotWaiters.removeAll()
- for waiter in waiters {
- waiter.resume(returning: false)
- }
- }
+ self.drainSnapshotWaiters(returning: false)
}
private func handleChannelDisconnected(_ reason: String) async {
@@ -311,13 +305,7 @@ public actor GatewayNodeSession {
private func markSnapshotReceived() {
self.snapshotReceived = true
- if !self.snapshotWaiters.isEmpty {
- let waiters = self.snapshotWaiters
- self.snapshotWaiters.removeAll()
- for waiter in waiters {
- waiter.resume(returning: true)
- }
- }
+ self.drainSnapshotWaiters(returning: true)
}
private func waitForSnapshot(timeoutMs: Int) async -> Bool {
@@ -335,11 +323,15 @@ public actor GatewayNodeSession {
private func timeoutSnapshotWaiters() {
guard !self.snapshotReceived else { return }
+ self.drainSnapshotWaiters(returning: false)
+ }
+
+ private func drainSnapshotWaiters(returning value: Bool) {
if !self.snapshotWaiters.isEmpty {
let waiters = self.snapshotWaiters
self.snapshotWaiters.removeAll()
for waiter in waiters {
- waiter.resume(returning: false)
+ waiter.resume(returning: value)
}
}
}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift
new file mode 100644
index 000000000000..86177b481862
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocalNetworkURLSupport.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+public enum LocalNetworkURLSupport {
+ public static func isLocalNetworkHTTPURL(_ url: URL) -> Bool {
+ guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
+ return false
+ }
+ guard let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty else {
+ return false
+ }
+ return LoopbackHost.isLocalNetworkHost(host)
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift
new file mode 100644
index 000000000000..80038d6016cd
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationCurrentRequest.swift
@@ -0,0 +1,44 @@
+import CoreLocation
+import Foundation
+
+public enum LocationCurrentRequest {
+ public typealias TimeoutRunner = @Sendable (
+ _ timeoutMs: Int,
+ _ operation: @escaping @Sendable () async throws -> CLLocation
+ ) async throws -> CLLocation
+
+ @MainActor
+ public static func resolve(
+ manager: CLLocationManager,
+ desiredAccuracy: OpenClawLocationAccuracy,
+ maxAgeMs: Int?,
+ timeoutMs: Int?,
+ request: @escaping @Sendable () async throws -> CLLocation,
+ withTimeout: TimeoutRunner) async throws -> CLLocation
+ {
+ let now = Date()
+ if let maxAgeMs,
+ let cached = manager.location,
+ now.timeIntervalSince(cached.timestamp) * 1000 <= Double(maxAgeMs)
+ {
+ return cached
+ }
+
+ manager.desiredAccuracy = self.accuracyValue(desiredAccuracy)
+ let timeout = max(0, timeoutMs ?? 10000)
+ return try await withTimeout(timeout) {
+ try await request()
+ }
+ }
+
+ public static func accuracyValue(_ accuracy: OpenClawLocationAccuracy) -> CLLocationAccuracy {
+ switch accuracy {
+ case .coarse:
+ kCLLocationAccuracyKilometer
+ case .balanced:
+ kCLLocationAccuracyHundredMeters
+ case .precise:
+ kCLLocationAccuracyBest
+ }
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift
new file mode 100644
index 000000000000..1a818c6c2624
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LocationServiceSupport.swift
@@ -0,0 +1,49 @@
+import CoreLocation
+import Foundation
+
+@MainActor
+public protocol LocationServiceCommon: AnyObject, CLLocationManagerDelegate {
+ var locationManager: CLLocationManager { get }
+ var locationRequestContinuation: CheckedContinuation? { get set }
+}
+
+public extension LocationServiceCommon {
+ func configureLocationManager() {
+ self.locationManager.delegate = self
+ self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
+ }
+
+ func authorizationStatus() -> CLAuthorizationStatus {
+ self.locationManager.authorizationStatus
+ }
+
+ func accuracyAuthorization() -> CLAccuracyAuthorization {
+ LocationServiceSupport.accuracyAuthorization(manager: self.locationManager)
+ }
+
+ func requestLocationOnce() async throws -> CLLocation {
+ try await LocationServiceSupport.requestLocation(manager: self.locationManager) { continuation in
+ self.locationRequestContinuation = continuation
+ }
+ }
+}
+
+public enum LocationServiceSupport {
+ public static func accuracyAuthorization(manager: CLLocationManager) -> CLAccuracyAuthorization {
+ if #available(iOS 14.0, macOS 11.0, *) {
+ return manager.accuracyAuthorization
+ }
+ return .fullAccuracy
+ }
+
+ @MainActor
+ public static func requestLocation(
+ manager: CLLocationManager,
+ setContinuation: @escaping (CheckedContinuation) -> Void) async throws -> CLLocation
+ {
+ try await withCheckedThrowingContinuation { continuation in
+ setContinuation(continuation)
+ manager.requestLocation()
+ }
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift
new file mode 100644
index 000000000000..b090549800af
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/LoopbackHost.swift
@@ -0,0 +1,80 @@
+import Foundation
+import Network
+
+public enum LoopbackHost {
+ public static func isLoopback(_ rawHost: String) -> Bool {
+ self.isLoopbackHost(rawHost)
+ }
+
+ public static func isLoopbackHost(_ rawHost: String) -> Bool {
+ var host = rawHost
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ .trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
+ if host.hasSuffix(".") {
+ host.removeLast()
+ }
+ if let zoneIndex = host.firstIndex(of: "%") {
+ host = String(host[.. Bool {
+ let host = rawHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !host.isEmpty else { return false }
+ if self.isLoopbackHost(host) { return true }
+ if host.hasSuffix(".local") { return true }
+ if host.hasSuffix(".ts.net") { return true }
+ if host.hasSuffix(".tailscale.net") { return true }
+ // Allow MagicDNS / LAN hostnames like "peters-mac-studio-1".
+ if !host.contains("."), !host.contains(":") { return true }
+ guard let ipv4 = self.parseIPv4(host) else { return false }
+ return self.isLocalNetworkIPv4(ipv4)
+ }
+
+ static func parseIPv4(_ host: String) -> (UInt8, UInt8, UInt8, UInt8)? {
+ 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 isLocalNetworkIPv4(_ ip: (UInt8, UInt8, UInt8, UInt8)) -> Bool {
+ let (a, b, _, _) = ip
+ // 10.0.0.0/8
+ if a == 10 { return true }
+ // 172.16.0.0/12
+ if a == 172, (16...31).contains(Int(b)) { return true }
+ // 192.168.0.0/16
+ if a == 192, b == 168 { return true }
+ // 127.0.0.0/8
+ if a == 127 { return true }
+ // 169.254.0.0/16 (link-local)
+ if a == 169, b == 254 { return true }
+ // Tailscale: 100.64.0.0/10
+ if a == 100, (64...127).contains(Int(b)) { return true }
+ return false
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift
index ab487bfd00a1..04d0ec4eba27 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/MotionCommands.swift
@@ -5,17 +5,7 @@ public enum OpenClawMotionCommand: String, Codable, Sendable {
case pedometer = "motion.pedometer"
}
-public struct OpenClawMotionActivityParams: Codable, Sendable, Equatable {
- public var startISO: String?
- public var endISO: String?
- public var limit: Int?
-
- public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
- self.startISO = startISO
- self.endISO = endISO
- self.limit = limit
- }
-}
+public typealias OpenClawMotionActivityParams = OpenClawDateRangeLimitParams
public struct OpenClawMotionActivityEntry: Codable, Sendable, Equatable {
public var startISO: String
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift
new file mode 100644
index 000000000000..57f2b08b920d
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaceIPv4.swift
@@ -0,0 +1,43 @@
+import Darwin
+import Foundation
+
+public enum NetworkInterfaceIPv4 {
+ public struct AddressEntry: Sendable {
+ public let name: String
+ public let ip: String
+ }
+
+ public static func addresses() -> [AddressEntry] {
+ var addrList: UnsafeMutablePointer?
+ guard getifaddrs(&addrList) == 0, let first = addrList else { return [] }
+ defer { freeifaddrs(addrList) }
+
+ var entries: [AddressEntry] = []
+ for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
+ let flags = Int32(ptr.pointee.ifa_flags)
+ let isUp = (flags & IFF_UP) != 0
+ let isLoopback = (flags & IFF_LOOPBACK) != 0
+ let family = ptr.pointee.ifa_addr.pointee.sa_family
+ if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
+
+ var addr = ptr.pointee.ifa_addr.pointee
+ var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
+ let result = getnameinfo(
+ &addr,
+ socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
+ &buffer,
+ socklen_t(buffer.count),
+ nil,
+ 0,
+ NI_NUMERICHOST)
+ guard result == 0 else { continue }
+
+ let len = buffer.prefix { $0 != 0 }
+ let bytes = len.map { UInt8(bitPattern: $0) }
+ guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
+ let name = String(cString: ptr.pointee.ifa_name)
+ entries.append(AddressEntry(name: name, ip: ip))
+ }
+ return entries
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift
index 3679ef542344..ac554e833909 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/NetworkInterfaces.swift
@@ -1,43 +1,17 @@
-import Darwin
import Foundation
public enum NetworkInterfaces {
public static func primaryIPv4Address() -> String? {
- var addrList: UnsafeMutablePointer?
- guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
- defer { freeifaddrs(addrList) }
-
var fallback: String?
var en0: String?
-
- for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
- let flags = Int32(ptr.pointee.ifa_flags)
- let isUp = (flags & IFF_UP) != 0
- let isLoopback = (flags & IFF_LOOPBACK) != 0
- let name = String(cString: ptr.pointee.ifa_name)
- let family = ptr.pointee.ifa_addr.pointee.sa_family
- if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
-
- var addr = ptr.pointee.ifa_addr.pointee
- var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- let result = getnameinfo(
- &addr,
- socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
- &buffer,
- socklen_t(buffer.count),
- nil,
- 0,
- NI_NUMERICHOST)
- guard result == 0 else { continue }
- let len = buffer.prefix { $0 != 0 }
- let bytes = len.map { UInt8(bitPattern: $0) }
- guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
-
- if name == "en0" { en0 = ip; break }
- if fallback == nil { fallback = ip }
+ for entry in NetworkInterfaceIPv4.addresses() {
+ if entry.name == "en0" {
+ en0 = entry.ip
+ break
+ }
+ if fallback == nil { fallback = entry.ip }
}
return en0 ?? fallback
}
}
-
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift
new file mode 100644
index 000000000000..5ff0b1170c83
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/OpenClawDateRangeLimitParams.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+public struct OpenClawDateRangeLimitParams: Codable, Sendable, Equatable {
+ public var startISO: String?
+ public var endISO: String?
+ public var limit: Int?
+
+ public init(startISO: String? = nil, endISO: String? = nil, limit: Int? = nil) {
+ self.startISO = startISO
+ self.endISO = endISO
+ self.limit = limit
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift
new file mode 100644
index 000000000000..42b22c95d25e
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ThrowingContinuationSupport.swift
@@ -0,0 +1,11 @@
+import Foundation
+
+public enum ThrowingContinuationSupport {
+ public static func resumeVoid(_ continuation: CheckedContinuation, error: Error?) {
+ if let error {
+ continuation.resume(throwing: error)
+ } else {
+ continuation.resume(returning: ())
+ }
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift
new file mode 100644
index 000000000000..2a9b37cb9c7b
--- /dev/null
+++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/WebViewJavaScriptSupport.swift
@@ -0,0 +1,57 @@
+import Foundation
+import WebKit
+
+public enum WebViewJavaScriptSupport {
+ @MainActor
+ public static func applyDebugStatus(
+ webView: WKWebView,
+ enabled: Bool,
+ title: String?,
+ subtitle: String?)
+ {
+ let js = """
+ (() => {
+ try {
+ const api = globalThis.__openclaw;
+ if (!api) return;
+ if (typeof api.setDebugStatusEnabled === 'function') {
+ api.setDebugStatusEnabled(\(enabled ? "true" : "false"));
+ }
+ if (!\(enabled ? "true" : "false")) return;
+ if (typeof api.setStatus === 'function') {
+ api.setStatus(\(self.jsValue(title)), \(self.jsValue(subtitle)));
+ }
+ } catch (_) {}
+ })()
+ """
+ webView.evaluateJavaScript(js) { _, _ in }
+ }
+
+ @MainActor
+ public static func evaluateToString(webView: WKWebView, javaScript: String) async throws -> String {
+ try await withCheckedThrowingContinuation { cont in
+ webView.evaluateJavaScript(javaScript) { result, error in
+ if let error {
+ cont.resume(throwing: error)
+ return
+ }
+ if let result {
+ cont.resume(returning: String(describing: result))
+ } else {
+ cont.resume(returning: "")
+ }
+ }
+ }
+ }
+
+ public static func jsValue(_ value: String?) -> String {
+ guard let value else { return "null" }
+ if let data = try? JSONSerialization.data(withJSONObject: [value]),
+ let encoded = String(data: data, encoding: .utf8),
+ encoded.count >= 2
+ {
+ return String(encoded.dropFirst().dropLast())
+ }
+ return "null"
+ }
+}
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 7aa2933479bb..6d138c70525d 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -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?
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
index 147b80e5be14..e7ba4523e682 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift
@@ -3,25 +3,126 @@ import Foundation
import Testing
@testable import OpenClawChatUI
-private struct TimeoutError: Error, CustomStringConvertible {
- let label: String
- var description: String { "Timeout waiting for: \(self.label)" }
+private func chatTextMessage(role: String, text: String, timestamp: Double) -> AnyCodable {
+ AnyCodable([
+ "role": role,
+ "content": [["type": "text", "text": text]],
+ "timestamp": timestamp,
+ ])
}
-private func waitUntil(
- _ label: String,
- timeoutSeconds: Double = 2.0,
- pollMs: UInt64 = 10,
- _ condition: @escaping @Sendable () async -> Bool) async throws
+private func historyPayload(
+ sessionKey: String = "main",
+ sessionId: String? = "sess-main",
+ messages: [AnyCodable] = []) -> OpenClawChatHistoryPayload
{
- let deadline = Date().addingTimeInterval(timeoutSeconds)
- while Date() < deadline {
- if await condition() {
- return
+ OpenClawChatHistoryPayload(
+ sessionKey: sessionKey,
+ sessionId: sessionId,
+ messages: messages,
+ thinkingLevel: "off")
+}
+
+private func sessionEntry(key: String, updatedAt: Double) -> OpenClawChatSessionEntry {
+ OpenClawChatSessionEntry(
+ key: key,
+ kind: nil,
+ displayName: nil,
+ surface: nil,
+ subject: nil,
+ room: nil,
+ space: nil,
+ updatedAt: updatedAt,
+ sessionId: nil,
+ systemSent: nil,
+ abortedLastRun: nil,
+ thinkingLevel: nil,
+ verboseLevel: nil,
+ inputTokens: nil,
+ outputTokens: nil,
+ totalTokens: nil,
+ model: nil,
+ contextTokens: nil)
+}
+
+private func makeViewModel(
+ sessionKey: String = "main",
+ historyResponses: [OpenClawChatHistoryPayload],
+ sessionsResponses: [OpenClawChatSessionsListResponse] = []) async -> (TestChatTransport, OpenClawChatViewModel)
+{
+ let transport = TestChatTransport(historyResponses: historyResponses, sessionsResponses: sessionsResponses)
+ let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: sessionKey, transport: transport) }
+ return (transport, vm)
+}
+
+private func loadAndWaitBootstrap(
+ vm: OpenClawChatViewModel,
+ sessionId: String? = nil) async throws
+{
+ await MainActor.run { vm.load() }
+ try await waitUntil("bootstrap") {
+ await MainActor.run {
+ vm.healthOK && (sessionId == nil || vm.sessionId == sessionId)
}
- try await Task.sleep(nanoseconds: pollMs * 1_000_000)
}
- throw TimeoutError(label: label)
+}
+
+private func sendUserMessage(_ vm: OpenClawChatViewModel, text: String = "hi") async {
+ await MainActor.run {
+ vm.input = text
+ vm.send()
+ }
+}
+
+private func emitAssistantText(
+ transport: TestChatTransport,
+ runId: String,
+ text: String,
+ seq: Int = 1)
+{
+ transport.emit(
+ .agent(
+ OpenClawAgentEventPayload(
+ runId: runId,
+ seq: seq,
+ stream: "assistant",
+ ts: Int(Date().timeIntervalSince1970 * 1000),
+ data: ["text": AnyCodable(text)])))
+}
+
+private func emitToolStart(
+ transport: TestChatTransport,
+ runId: String,
+ seq: Int = 2)
+{
+ transport.emit(
+ .agent(
+ OpenClawAgentEventPayload(
+ runId: runId,
+ seq: seq,
+ stream: "tool",
+ ts: Int(Date().timeIntervalSince1970 * 1000),
+ data: [
+ "phase": AnyCodable("start"),
+ "name": AnyCodable("demo"),
+ "toolCallId": AnyCodable("t1"),
+ "args": AnyCodable(["x": 1]),
+ ])))
+}
+
+private func emitExternalFinal(
+ transport: TestChatTransport,
+ runId: String = "other-run",
+ sessionKey: String = "main")
+{
+ transport.emit(
+ .chat(
+ OpenClawChatEventPayload(
+ runId: runId,
+ sessionKey: sessionKey,
+ state: "final",
+ message: nil,
+ errorMessage: nil)))
}
private actor TestChatTransportState {
@@ -139,61 +240,28 @@ extension TestChatTransportState {
@Suite struct ChatViewModelTests {
@Test func streamsAssistantAndClearsOnFinal() async throws {
let sessionId = "sess-main"
- let history1 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: sessionId,
- messages: [],
- thinkingLevel: "off")
- let history2 = OpenClawChatHistoryPayload(
- sessionKey: "main",
+ let history1 = historyPayload(sessionId: sessionId)
+ let history2 = historyPayload(
sessionId: sessionId,
messages: [
- AnyCodable([
- "role": "assistant",
- "content": [["type": "text", "text": "final answer"]],
- "timestamp": Date().timeIntervalSince1970 * 1000,
- ]),
- ],
- thinkingLevel: "off")
-
- let transport = TestChatTransport(historyResponses: [history1, history2])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
-
- await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
+ chatTextMessage(
+ role: "assistant",
+ text: "final answer",
+ timestamp: Date().timeIntervalSince1970 * 1000),
+ ])
- await MainActor.run {
- vm.input = "hi"
- vm.send()
- }
+ let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
+ try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
+ await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
- transport.emit(
- .agent(
- OpenClawAgentEventPayload(
- runId: sessionId,
- seq: 1,
- stream: "assistant",
- ts: Int(Date().timeIntervalSince1970 * 1000),
- data: ["text": AnyCodable("streaming…")])))
+ emitAssistantText(transport: transport, runId: sessionId, text: "streaming…")
try await waitUntil("assistant stream visible") {
await MainActor.run { vm.streamingAssistantText == "streaming…" }
}
- transport.emit(
- .agent(
- OpenClawAgentEventPayload(
- runId: sessionId,
- seq: 2,
- stream: "tool",
- ts: Int(Date().timeIntervalSince1970 * 1000),
- data: [
- "phase": AnyCodable("start"),
- "name": AnyCodable("demo"),
- "toolCallId": AnyCodable("t1"),
- "args": AnyCodable(["x": 1]),
- ])))
+ emitToolStart(transport: transport, runId: sessionId)
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
@@ -216,33 +284,18 @@ extension TestChatTransportState {
}
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
- let history1 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
- messages: [],
- thinkingLevel: "off")
- let history2 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
+ let history1 = historyPayload()
+ let history2 = historyPayload(
messages: [
- AnyCodable([
- "role": "assistant",
- "content": [["type": "text", "text": "from history"]],
- "timestamp": Date().timeIntervalSince1970 * 1000,
- ]),
- ],
- thinkingLevel: "off")
-
- let transport = TestChatTransport(historyResponses: [history1, history2])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
-
- await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
+ chatTextMessage(
+ role: "assistant",
+ text: "from history",
+ timestamp: Date().timeIntervalSince1970 * 1000),
+ ])
- await MainActor.run {
- vm.input = "hi"
- vm.send()
- }
+ let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
+ try await loadAndWaitBootstrap(vm: vm)
+ await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
@@ -263,39 +316,17 @@ extension TestChatTransportState {
@Test func acceptsCanonicalSessionKeyEventsForExternalRuns() async throws {
let now = Date().timeIntervalSince1970 * 1000
- let history1 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
- messages: [
- AnyCodable([
- "role": "user",
- "content": [["type": "text", "text": "first"]],
- "timestamp": now,
- ]),
- ],
- thinkingLevel: "off")
- let history2 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
+ let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "first", timestamp: now)])
+ let history2 = historyPayload(
messages: [
- AnyCodable([
- "role": "user",
- "content": [["type": "text", "text": "first"]],
- "timestamp": now,
- ]),
- AnyCodable([
- "role": "assistant",
- "content": [["type": "text", "text": "from external run"]],
- "timestamp": now + 1,
- ]),
- ],
- thinkingLevel: "off")
+ chatTextMessage(role: "user", text: "first", timestamp: now),
+ chatTextMessage(role: "assistant", text: "from external run", timestamp: now + 1),
+ ])
- let transport = TestChatTransport(historyResponses: [history1, history2])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+ let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
+ try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.count == 1 } }
transport.emit(
.chat(
@@ -313,49 +344,20 @@ extension TestChatTransportState {
@Test func preservesMessageIDsAcrossHistoryRefreshes() async throws {
let now = Date().timeIntervalSince1970 * 1000
- let history1 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
- messages: [
- AnyCodable([
- "role": "user",
- "content": [["type": "text", "text": "hello"]],
- "timestamp": now,
- ]),
- ],
- thinkingLevel: "off")
- let history2 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
+ let history1 = historyPayload(messages: [chatTextMessage(role: "user", text: "hello", timestamp: now)])
+ let history2 = historyPayload(
messages: [
- AnyCodable([
- "role": "user",
- "content": [["type": "text", "text": "hello"]],
- "timestamp": now,
- ]),
- AnyCodable([
- "role": "assistant",
- "content": [["type": "text", "text": "world"]],
- "timestamp": now + 1,
- ]),
- ],
- thinkingLevel: "off")
+ chatTextMessage(role: "user", text: "hello", timestamp: now),
+ chatTextMessage(role: "assistant", text: "world", timestamp: now + 1),
+ ])
- let transport = TestChatTransport(historyResponses: [history1, history2])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+ let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
+ try await waitUntil("bootstrap history loaded") { await MainActor.run { vm.messages.count == 1 } }
let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
- transport.emit(
- .chat(
- OpenClawChatEventPayload(
- runId: "other-run",
- sessionKey: "main",
- state: "final",
- message: nil,
- errorMessage: nil)))
+ emitExternalFinal(transport: transport)
try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
@@ -364,53 +366,19 @@ extension TestChatTransportState {
@Test func clearsStreamingOnExternalFinalEvent() async throws {
let sessionId = "sess-main"
- let history = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: sessionId,
- messages: [],
- thinkingLevel: "off")
- let transport = TestChatTransport(historyResponses: [history, history])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+ let history = historyPayload(sessionId: sessionId)
+ let (transport, vm) = await makeViewModel(historyResponses: [history, history])
+ try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
- await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
-
- transport.emit(
- .agent(
- OpenClawAgentEventPayload(
- runId: sessionId,
- seq: 1,
- stream: "assistant",
- ts: Int(Date().timeIntervalSince1970 * 1000),
- data: ["text": AnyCodable("external stream")])))
-
- transport.emit(
- .agent(
- OpenClawAgentEventPayload(
- runId: sessionId,
- seq: 2,
- stream: "tool",
- ts: Int(Date().timeIntervalSince1970 * 1000),
- data: [
- "phase": AnyCodable("start"),
- "name": AnyCodable("demo"),
- "toolCallId": AnyCodable("t1"),
- "args": AnyCodable(["x": 1]),
- ])))
+ emitAssistantText(transport: transport, runId: sessionId, text: "external stream")
+ emitToolStart(transport: transport, runId: sessionId)
try await waitUntil("streaming active") {
await MainActor.run { vm.streamingAssistantText == "external stream" }
}
try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } }
- transport.emit(
- .chat(
- OpenClawChatEventPayload(
- runId: "other-run",
- sessionKey: "main",
- state: "final",
- message: nil,
- errorMessage: nil)))
+ emitExternalFinal(transport: transport)
try await waitUntil("streaming cleared") { await MainActor.run { vm.streamingAssistantText == nil } }
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
@@ -418,33 +386,14 @@ extension TestChatTransportState {
@Test func seqGapClearsPendingRunsAndAutoRefreshesHistory() async throws {
let now = Date().timeIntervalSince1970 * 1000
- let history1 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
- messages: [],
- thinkingLevel: "off")
- let history2 = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
- messages: [
- AnyCodable([
- "role": "assistant",
- "content": [["type": "text", "text": "resynced after gap"]],
- "timestamp": now,
- ]),
- ],
- thinkingLevel: "off")
+ let history1 = historyPayload()
+ let history2 = historyPayload(messages: [chatTextMessage(role: "assistant", text: "resynced after gap", timestamp: now)])
- let transport = TestChatTransport(historyResponses: [history1, history2])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+ let (transport, vm) = await makeViewModel(historyResponses: [history1, history2])
- await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
+ try await loadAndWaitBootstrap(vm: vm)
- await MainActor.run {
- vm.input = "hello"
- vm.send()
- }
+ await sendUserMessage(vm, text: "hello")
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
transport.emit(.seqGap)
@@ -463,99 +412,20 @@ extension TestChatTransportState {
let recent = now - (2 * 60 * 60 * 1000)
let recentOlder = now - (5 * 60 * 60 * 1000)
let stale = now - (26 * 60 * 60 * 1000)
- let history = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: "sess-main",
- messages: [],
- thinkingLevel: "off")
+ let history = historyPayload()
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 4,
defaults: nil,
sessions: [
- OpenClawChatSessionEntry(
- key: "recent-1",
- kind: nil,
- displayName: nil,
- surface: nil,
- subject: nil,
- room: nil,
- space: nil,
- updatedAt: recent,
- sessionId: nil,
- systemSent: nil,
- abortedLastRun: nil,
- thinkingLevel: nil,
- verboseLevel: nil,
- inputTokens: nil,
- outputTokens: nil,
- totalTokens: nil,
- model: nil,
- contextTokens: nil),
- OpenClawChatSessionEntry(
- key: "main",
- kind: nil,
- displayName: nil,
- surface: nil,
- subject: nil,
- room: nil,
- space: nil,
- updatedAt: stale,
- sessionId: nil,
- systemSent: nil,
- abortedLastRun: nil,
- thinkingLevel: nil,
- verboseLevel: nil,
- inputTokens: nil,
- outputTokens: nil,
- totalTokens: nil,
- model: nil,
- contextTokens: nil),
- OpenClawChatSessionEntry(
- key: "recent-2",
- kind: nil,
- displayName: nil,
- surface: nil,
- subject: nil,
- room: nil,
- space: nil,
- updatedAt: recentOlder,
- sessionId: nil,
- systemSent: nil,
- abortedLastRun: nil,
- thinkingLevel: nil,
- verboseLevel: nil,
- inputTokens: nil,
- outputTokens: nil,
- totalTokens: nil,
- model: nil,
- contextTokens: nil),
- OpenClawChatSessionEntry(
- key: "old-1",
- kind: nil,
- displayName: nil,
- surface: nil,
- subject: nil,
- room: nil,
- space: nil,
- updatedAt: stale,
- sessionId: nil,
- systemSent: nil,
- abortedLastRun: nil,
- thinkingLevel: nil,
- verboseLevel: nil,
- inputTokens: nil,
- outputTokens: nil,
- totalTokens: nil,
- model: nil,
- contextTokens: nil),
+ sessionEntry(key: "recent-1", updatedAt: recent),
+ sessionEntry(key: "main", updatedAt: stale),
+ sessionEntry(key: "recent-2", updatedAt: recentOlder),
+ sessionEntry(key: "old-1", updatedAt: stale),
])
- let transport = TestChatTransport(
- historyResponses: [history],
- sessionsResponses: [sessions])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+ let (_, vm) = await makeViewModel(historyResponses: [history], sessionsResponses: [sessions])
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
@@ -566,42 +436,20 @@ extension TestChatTransportState {
@Test func sessionChoicesIncludeCurrentWhenMissing() async throws {
let now = Date().timeIntervalSince1970 * 1000
let recent = now - (30 * 60 * 1000)
- let history = OpenClawChatHistoryPayload(
- sessionKey: "custom",
- sessionId: "sess-custom",
- messages: [],
- thinkingLevel: "off")
+ let history = historyPayload(sessionKey: "custom", sessionId: "sess-custom")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 1,
defaults: nil,
sessions: [
- OpenClawChatSessionEntry(
- key: "main",
- kind: nil,
- displayName: nil,
- surface: nil,
- subject: nil,
- room: nil,
- space: nil,
- updatedAt: recent,
- sessionId: nil,
- systemSent: nil,
- abortedLastRun: nil,
- thinkingLevel: nil,
- verboseLevel: nil,
- inputTokens: nil,
- outputTokens: nil,
- totalTokens: nil,
- model: nil,
- contextTokens: nil),
+ sessionEntry(key: "main", updatedAt: recent),
])
- let transport = TestChatTransport(
+ let (_, vm) = await makeViewModel(
+ sessionKey: "custom",
historyResponses: [history],
sessionsResponses: [sessions])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "custom", transport: transport) }
await MainActor.run { vm.load() }
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
@@ -611,25 +459,11 @@ extension TestChatTransportState {
@Test func clearsStreamingOnExternalErrorEvent() async throws {
let sessionId = "sess-main"
- let history = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: sessionId,
- messages: [],
- thinkingLevel: "off")
- let transport = TestChatTransport(historyResponses: [history, history])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
+ let history = historyPayload(sessionId: sessionId)
+ let (transport, vm) = await makeViewModel(historyResponses: [history, history])
+ try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
- await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
-
- transport.emit(
- .agent(
- OpenClawAgentEventPayload(
- runId: sessionId,
- seq: 1,
- stream: "assistant",
- ts: Int(Date().timeIntervalSince1970 * 1000),
- data: ["text": AnyCodable("external stream")])))
+ emitAssistantText(transport: transport, runId: sessionId, text: "external stream")
try await waitUntil("streaming active") {
await MainActor.run { vm.streamingAssistantText == "external stream" }
@@ -678,21 +512,11 @@ Hello?
@Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws {
let sessionId = "sess-main"
- let history = OpenClawChatHistoryPayload(
- sessionKey: "main",
- sessionId: sessionId,
- messages: [],
- thinkingLevel: "off")
- let transport = TestChatTransport(historyResponses: [history, history])
- let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
-
- await MainActor.run { vm.load() }
- try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } }
+ let history = historyPayload(sessionId: sessionId)
+ let (transport, vm) = await makeViewModel(historyResponses: [history, history])
+ try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
- await MainActor.run {
- vm.input = "hi"
- vm.send()
- }
+ await sendUserMessage(vm)
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
let runId = try #require(await transport.lastSentRunId())
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift
index 08a6ea2162a2..a706e4bdb4cd 100644
--- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift
@@ -3,27 +3,6 @@ import Testing
@testable import OpenClawKit
import OpenClawProtocol
-private struct TimeoutError: Error, CustomStringConvertible {
- let label: String
- var description: String { "Timeout waiting for: \(self.label)" }
-}
-
-private func waitUntil(
- _ label: String,
- timeoutSeconds: Double = 3.0,
- pollMs: UInt64 = 10,
- _ condition: @escaping @Sendable () async -> Bool) async throws
-{
- let deadline = Date().addingTimeInterval(timeoutSeconds)
- while Date() < deadline {
- if await condition() {
- return
- }
- try await Task.sleep(nanoseconds: pollMs * 1_000_000)
- }
- throw TimeoutError(label: label)
-}
-
private extension NSLock {
func withLock(_ body: () -> T) -> T {
self.lock()
@@ -114,38 +93,48 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
private static func connectChallengeData(nonce: String) -> Data {
- let json = """
- {
- "type": "event",
- "event": "connect.challenge",
- "payload": { "nonce": "\(nonce)" }
- }
- """
- return Data(json.utf8)
+ let frame: [String: Any] = [
+ "type": "event",
+ "event": "connect.challenge",
+ "payload": ["nonce": nonce],
+ ]
+ return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
private static func connectOkData(id: String) -> Data {
- let json = """
- {
- "type": "res",
- "id": "\(id)",
- "ok": true,
- "payload": {
+ let payload: [String: Any] = [
"type": "hello-ok",
"protocol": 2,
- "server": { "version": "test", "connId": "test" },
- "features": { "methods": [], "events": [] },
- "snapshot": {
- "presence": [ { "ts": 1 } ],
- "health": {},
- "stateVersion": { "presence": 0, "health": 0 },
- "uptimeMs": 0
- },
- "policy": { "maxPayload": 1, "maxBufferedBytes": 1, "tickIntervalMs": 30000 }
- }
- }
- """
- return Data(json.utf8)
+ "server": [
+ "version": "test",
+ "connId": "test",
+ ],
+ "features": [
+ "methods": [],
+ "events": [],
+ ],
+ "snapshot": [
+ "presence": [["ts": 1]],
+ "health": [:],
+ "stateVersion": [
+ "presence": 0,
+ "health": 0,
+ ],
+ "uptimeMs": 0,
+ ],
+ "policy": [
+ "maxPayload": 1,
+ "maxBufferedBytes": 1,
+ "tickIntervalMs": 30_000,
+ ],
+ ]
+ let frame: [String: Any] = [
+ "type": "res",
+ "id": id,
+ "ok": true,
+ "payload": payload,
+ ]
+ return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
}
}
diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TestAsyncHelpers.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TestAsyncHelpers.swift
new file mode 100644
index 000000000000..77c1b1a1793e
--- /dev/null
+++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/TestAsyncHelpers.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+struct AsyncWaitTimeoutError: Error, CustomStringConvertible {
+ let label: String
+ var description: String { "Timeout waiting for: \(self.label)" }
+}
+
+func waitUntil(
+ _ label: String,
+ timeoutSeconds: Double = 3.0,
+ pollMs: UInt64 = 10,
+ _ condition: @escaping @Sendable () async -> Bool) async throws
+{
+ let deadline = Date().addingTimeInterval(timeoutSeconds)
+ while Date() < deadline {
+ if await condition() {
+ return
+ }
+ try await Task.sleep(nanoseconds: pollMs * 1_000_000)
+ }
+ throw AsyncWaitTimeoutError(label: label)
+}
diff --git a/assets/chrome-extension/background.js b/assets/chrome-extension/background.js
index c78f2c7c4527..0c4252f3a854 100644
--- a/assets/chrome-extension/background.js
+++ b/assets/chrome-extension/background.js
@@ -277,12 +277,24 @@ async function reannounceAttachedTabs() {
}
// Send fresh attach event to relay.
+ // Split into two try-catch blocks so debugger failures and relay send
+ // failures are handled independently. Previously, a relay send failure
+ // would fall into the outer catch and set the badge to 'on' even though
+ // the relay had no record of the tab — causing every subsequent browser
+ // tool call to fail with "no tab connected" until the next reconnect cycle.
+ let targetInfo
try {
const info = /** @type {any} */ (
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
)
- const targetInfo = info?.targetInfo
+ targetInfo = info?.targetInfo
+ } catch {
+ // Target.getTargetInfo failed. Preserve at least targetId from
+ // cached tab state so relay receives a stable identifier.
+ targetInfo = tab.targetId ? { targetId: tab.targetId } : undefined
+ }
+ try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
@@ -301,7 +313,15 @@ async function reannounceAttachedTabs() {
title: 'OpenClaw Browser Relay: attached (click to detach)',
})
} catch {
- setBadge(tabId, 'on')
+ // Relay send failed (e.g. WS closed in the gap between ensureRelayConnection
+ // resolving and this loop executing). The tab is still valid — leave badge
+ // as 'connecting' so the reconnect/keepalive cycle will retry rather than
+ // showing a false-positive 'on' that hides the broken state from the user.
+ setBadge(tabId, 'connecting')
+ void chrome.action.setTitle({
+ tabId,
+ title: 'OpenClaw Browser Relay: relay reconnecting…',
+ })
}
}
@@ -769,7 +789,11 @@ async function onDebuggerDetach(source, reason) {
title: 'OpenClaw Browser Relay: re-attaching after navigation…',
})
- const delays = [300, 700, 1500]
+ // Extend re-attach window from 2.5 s to ~7.7 s (5 attempts).
+ // SPAs and pages with heavy JS can take >2.5 s before the Chrome debugger
+ // is attachable, causing all three original attempts to fail and leaving
+ // the badge permanently off after every navigation.
+ const delays = [200, 500, 1000, 2000, 4000]
for (let attempt = 0; attempt < delays.length; attempt++) {
await new Promise((r) => setTimeout(r, delays[attempt]))
@@ -783,19 +807,21 @@ async function onDebuggerDetach(source, reason) {
return
}
- if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
- reattachPending.delete(tabId)
- setBadge(tabId, 'error')
- void chrome.action.setTitle({
- tabId,
- title: 'OpenClaw Browser Relay: relay disconnected during re-attach',
- })
- return
- }
+ const relayUp = relayWs && relayWs.readyState === WebSocket.OPEN
try {
- await attachTab(tabId)
+ // When relay is down, still attach the debugger but skip sending the
+ // relay event. reannounceAttachedTabs() will notify the relay once it
+ // reconnects, so the tab stays tracked across transient relay drops.
+ await attachTab(tabId, { skipAttachedEvent: !relayUp })
reattachPending.delete(tabId)
+ if (!relayUp) {
+ setBadge(tabId, 'connecting')
+ void chrome.action.setTitle({
+ tabId,
+ title: 'OpenClaw Browser Relay: attached, waiting for relay reconnect…',
+ })
+ }
return
} catch {
// continue retries
diff --git a/changelog/fragments/README.md b/changelog/fragments/README.md
deleted file mode 100644
index 93bb5b65d706..000000000000
--- a/changelog/fragments/README.md
+++ /dev/null
@@ -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-.md`.
-- Include at least one line with both `#` and `thanks @`.
-
-Example:
-
-```md
-- Fix LINE monitor lifecycle wait ownership (#27001) (thanks @alice)
-```
diff --git a/changelog/fragments/pr-5080.md b/changelog/fragments/pr-5080.md
deleted file mode 100644
index 62ccadaad4c6..000000000000
--- a/changelog/fragments/pr-5080.md
+++ /dev/null
@@ -1 +0,0 @@
-- Clarify block reply pipeline seen-check parameter naming for maintainability (#5080) (thanks @yassine20011)
diff --git a/changelog/fragments/pr-5343.md b/changelog/fragments/pr-5343.md
deleted file mode 100644
index 44ffc8321a91..000000000000
--- a/changelog/fragments/pr-5343.md
+++ /dev/null
@@ -1 +0,0 @@
-- Memory flush: fix usage-threshold gating and transcript fallback paths so flushes run reliably when expected (#5343) (thanks @jarvis-medmatic)
diff --git a/docker-compose.yml b/docker-compose.yml
index 7177c7d1ac31..a17558157f79 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,12 +5,21 @@ services:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
+ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
+ ## Uncomment the lines below to enable sandbox isolation
+ ## (agents.defaults.sandbox). Requires Docker CLI in the image
+ ## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
+ ## docker-setup.sh with OPENCLAW_SANDBOX=1 for automated setup.
+ ## Set DOCKER_GID to the host's docker group GID (run: stat -c '%g' /var/run/docker.sock).
+ # - /var/run/docker.sock:/var/run/docker.sock
+ # group_add:
+ # - "${DOCKER_GID:-999}"
ports:
- "${OPENCLAW_GATEWAY_PORT:-18789}:18789"
- "${OPENCLAW_BRIDGE_PORT:-18790}:18790"
@@ -51,6 +60,7 @@ services:
HOME: /home/node
TERM: xterm-256color
OPENCLAW_GATEWAY_TOKEN: ${OPENCLAW_GATEWAY_TOKEN}
+ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: ${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}
BROWSER: echo
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
diff --git a/docker-setup.sh b/docker-setup.sh
index 61f66ec6d802..ce5e6a08f3d4 100755
--- a/docker-setup.sh
+++ b/docker-setup.sh
@@ -7,6 +7,9 @@ EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}"
EXTRA_MOUNTS="${OPENCLAW_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
+RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
+SANDBOX_ENABLED=""
+DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
fail() {
echo "ERROR: $*" >&2
@@ -20,6 +23,15 @@ require_cmd() {
fi
}
+is_truthy_value() {
+ local raw="${1:-}"
+ raw="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]')"
+ case "$raw" in
+ 1 | true | yes | on) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
read_config_gateway_token() {
local config_path="$OPENCLAW_CONFIG_DIR/openclaw.json"
if [[ ! -f "$config_path" ]]; then
@@ -144,6 +156,16 @@ if ! docker compose version >/dev/null 2>&1; then
exit 1
fi
+if [[ -z "$DOCKER_SOCKET_PATH" && "${DOCKER_HOST:-}" == unix://* ]]; then
+ DOCKER_SOCKET_PATH="${DOCKER_HOST#unix://}"
+fi
+if [[ -z "$DOCKER_SOCKET_PATH" ]]; then
+ DOCKER_SOCKET_PATH="/var/run/docker.sock"
+fi
+if is_truthy_value "$RAW_SANDBOX_SETTING"; then
+ SANDBOX_ENABLED="1"
+fi
+
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}"
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
@@ -159,6 +181,9 @@ fi
if contains_disallowed_chars "$EXTRA_MOUNTS"; then
fail "OPENCLAW_EXTRA_MOUNTS cannot contain control characters."
fi
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
+fi
mkdir -p "$OPENCLAW_CONFIG_DIR"
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
@@ -177,6 +202,16 @@ export OPENCLAW_IMAGE="$IMAGE_NAME"
export OPENCLAW_DOCKER_APT_PACKAGES="${OPENCLAW_DOCKER_APT_PACKAGES:-}"
export OPENCLAW_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export OPENCLAW_HOME_VOLUME="$HOME_VOLUME_NAME"
+export OPENCLAW_ALLOW_INSECURE_PRIVATE_WS="${OPENCLAW_ALLOW_INSECURE_PRIVATE_WS:-}"
+export OPENCLAW_SANDBOX="$SANDBOX_ENABLED"
+export OPENCLAW_DOCKER_SOCKET="$DOCKER_SOCKET_PATH"
+
+# Detect Docker socket GID for sandbox group_add.
+DOCKER_GID=""
+if [[ -n "$SANDBOX_ENABLED" && -S "$DOCKER_SOCKET_PATH" ]]; then
+ DOCKER_GID="$(stat -c '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || stat -f '%g' "$DOCKER_SOCKET_PATH" 2>/dev/null || echo "")"
+fi
+export DOCKER_GID
if [[ -z "${OPENCLAW_GATEWAY_TOKEN:-}" ]]; then
EXISTING_CONFIG_TOKEN="$(read_config_gateway_token || true)"
@@ -254,6 +289,14 @@ YAML
fi
}
+# When sandbox is requested, ensure Docker CLI build arg is set for local builds.
+# Docker socket mount is deferred until sandbox prerequisites are verified.
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ if [[ -z "${OPENCLAW_INSTALL_DOCKER_CLI:-}" ]]; then
+ export OPENCLAW_INSTALL_DOCKER_CLI=1
+ fi
+fi
+
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
@@ -278,6 +321,9 @@ fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
+# Keep a base compose arg set without sandbox overlay so rollback paths can
+# force a known-safe gateway service definition (no docker.sock mount).
+BASE_COMPOSE_ARGS=("${COMPOSE_ARGS[@]}")
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
@@ -331,12 +377,18 @@ upsert_env "$ENV_FILE" \
OPENCLAW_IMAGE \
OPENCLAW_EXTRA_MOUNTS \
OPENCLAW_HOME_VOLUME \
- OPENCLAW_DOCKER_APT_PACKAGES
+ OPENCLAW_DOCKER_APT_PACKAGES \
+ OPENCLAW_SANDBOX \
+ OPENCLAW_DOCKER_SOCKET \
+ DOCKER_GID \
+ OPENCLAW_INSTALL_DOCKER_CLI \
+ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
echo "==> Building Docker image: $IMAGE_NAME"
docker build \
--build-arg "OPENCLAW_DOCKER_APT_PACKAGES=${OPENCLAW_DOCKER_APT_PACKAGES}" \
+ --build-arg "OPENCLAW_INSTALL_DOCKER_CLI=${OPENCLAW_INSTALL_DOCKER_CLI:-}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
@@ -397,6 +449,115 @@ echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
+# --- Sandbox setup (opt-in via OPENCLAW_SANDBOX=1) ---
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ echo ""
+ echo "==> Sandbox setup"
+
+ # Build sandbox image if Dockerfile.sandbox exists.
+ if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then
+ echo "Building sandbox image: openclaw-sandbox:bookworm-slim"
+ docker build \
+ -t "openclaw-sandbox:bookworm-slim" \
+ -f "$ROOT_DIR/Dockerfile.sandbox" \
+ "$ROOT_DIR"
+ else
+ echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2
+ echo " Sandbox config will be applied but no sandbox image will be built." >&2
+ echo " Agent exec may fail if the configured sandbox image does not exist." >&2
+ fi
+
+ # Defense-in-depth: verify Docker CLI in the running image before enabling
+ # sandbox. This avoids claiming sandbox is enabled when the image cannot
+ # launch sandbox containers.
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --entrypoint docker openclaw-gateway --version >/dev/null 2>&1; then
+ echo "WARNING: Docker CLI not found inside the container image." >&2
+ echo " Sandbox requires Docker CLI. Rebuild with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1" >&2
+ echo " or use a local build (OPENCLAW_IMAGE=openclaw:local). Skipping sandbox setup." >&2
+ SANDBOX_ENABLED=""
+ fi
+fi
+
+# Apply sandbox config only if prerequisites are met.
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ # Mount Docker socket via a dedicated compose overlay. This overlay is
+ # created only after sandbox prerequisites pass, so the socket is never
+ # exposed when sandbox cannot actually run.
+ if [[ -S "$DOCKER_SOCKET_PATH" ]]; then
+ SANDBOX_COMPOSE_FILE="$ROOT_DIR/docker-compose.sandbox.yml"
+ cat >"$SANDBOX_COMPOSE_FILE" <>"$SANDBOX_COMPOSE_FILE" < Sandbox: added Docker socket mount"
+ else
+ echo "WARNING: OPENCLAW_SANDBOX enabled but Docker socket not found at $DOCKER_SOCKET_PATH." >&2
+ echo " Sandbox requires Docker socket access. Skipping sandbox setup." >&2
+ SANDBOX_ENABLED=""
+ fi
+fi
+
+if [[ -n "$SANDBOX_ENABLED" ]]; then
+ # Enable sandbox in OpenClaw config.
+ sandbox_config_ok=true
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.mode "non-main" >/dev/null; then
+ echo "WARNING: Failed to set agents.defaults.sandbox.mode" >&2
+ sandbox_config_ok=false
+ fi
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.scope "agent" >/dev/null; then
+ echo "WARNING: Failed to set agents.defaults.sandbox.scope" >&2
+ sandbox_config_ok=false
+ fi
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.workspaceAccess "none" >/dev/null; then
+ echo "WARNING: Failed to set agents.defaults.sandbox.workspaceAccess" >&2
+ sandbox_config_ok=false
+ fi
+
+ if [[ "$sandbox_config_ok" == true ]]; then
+ echo "Sandbox enabled: mode=non-main, scope=agent, workspaceAccess=none"
+ echo "Docs: https://docs.openclaw.ai/gateway/sandboxing"
+ # Restart gateway with sandbox compose overlay to pick up socket mount + config.
+ docker compose "${COMPOSE_ARGS[@]}" up -d openclaw-gateway
+ else
+ echo "WARNING: Sandbox config was partially applied. Check errors above." >&2
+ echo " Skipping gateway restart to avoid exposing Docker socket without a full sandbox policy." >&2
+ if ! docker compose "${BASE_COMPOSE_ARGS[@]}" run --rm --no-deps openclaw-cli \
+ config set agents.defaults.sandbox.mode "off" >/dev/null; then
+ echo "WARNING: Failed to roll back agents.defaults.sandbox.mode to off" >&2
+ else
+ echo "Sandbox mode rolled back to off due to partial sandbox config failure."
+ fi
+ if [[ -n "${SANDBOX_COMPOSE_FILE:-}" ]]; then
+ rm -f "$SANDBOX_COMPOSE_FILE"
+ fi
+ # Ensure gateway service definition is reset without sandbox overlay mount.
+ docker compose "${BASE_COMPOSE_ARGS[@]}" up -d --force-recreate openclaw-gateway
+ fi
+else
+ # Keep reruns deterministic: if sandbox is not active for this run, reset
+ # persisted sandbox mode so future execs do not require docker.sock by stale
+ # config alone.
+ if ! docker compose "${COMPOSE_ARGS[@]}" run --rm openclaw-cli \
+ config set agents.defaults.sandbox.mode "off" >/dev/null; then
+ echo "WARNING: Failed to reset agents.defaults.sandbox.mode to off" >&2
+ fi
+ if [[ -f "$ROOT_DIR/docker-compose.sandbox.yml" ]]; then
+ rm -f "$ROOT_DIR/docker-compose.sandbox.yml"
+ fi
+fi
+
echo ""
echo "Gateway running with host port mapping."
echo "Access from tailnet devices via the host's tailnet IP."
diff --git a/docs/assets/sponsors/vercel.svg b/docs/assets/sponsors/vercel.svg
new file mode 100644
index 000000000000..d77a5448727f
--- /dev/null
+++ b/docs/assets/sponsors/vercel.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md
index 0f561741d9a8..d34480f1ed31 100644
--- a/docs/automation/hooks.md
+++ b/docs/automation/hooks.md
@@ -258,7 +258,9 @@ Triggered when the gateway starts:
Triggered when messages are received or sent:
- **`message`**: All message events (general listener)
-- **`message:received`**: When an inbound message is received from any channel
+- **`message:received`**: When an inbound message is received from any channel. Fires early in processing before media understanding. Content may contain raw placeholders like `` for media attachments that haven't been processed yet.
+- **`message:transcribed`**: When a message has been fully processed, including audio transcription and link understanding. At this point, `transcript` contains the full transcript text for audio messages. Use this hook when you need access to transcribed audio content.
+- **`message:preprocessed`**: Fires for every message after all media + link understanding completes, giving hooks access to the fully enriched body (transcripts, image descriptions, link summaries) before the agent sees it.
- **`message:sent`**: When an outbound message is successfully sent
#### Message Event Context
@@ -297,6 +299,30 @@ Message events include rich context about the message:
accountId?: string, // Provider account ID
conversationId?: string, // Chat/conversation ID
messageId?: string, // Message ID returned by the provider
+ isGroup?: boolean, // Whether this outbound message belongs to a group/channel context
+ groupId?: string, // Group/channel identifier for correlation with message:received
+}
+
+// message:transcribed context
+{
+ body?: string, // Raw inbound body before enrichment
+ bodyForAgent?: string, // Enriched body visible to the agent
+ transcript: string, // Audio transcript text
+ channelId: string, // Channel (e.g., "telegram", "whatsapp")
+ conversationId?: string,
+ messageId?: string,
+}
+
+// message:preprocessed context
+{
+ body?: string, // Raw inbound body
+ bodyForAgent?: string, // Final enriched body after media/link understanding
+ transcript?: string, // Transcript when audio was present
+ channelId: string, // Channel (e.g., "telegram", "whatsapp")
+ conversationId?: string,
+ messageId?: string,
+ isGroup?: boolean,
+ groupId?: string,
}
```
diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md
index 8072b4a1a3f4..b35ee9d44693 100644
--- a/docs/automation/webhook.md
+++ b/docs/automation/webhook.md
@@ -159,7 +159,7 @@ Mapping options (summary):
## Responses
- `200` for `/hooks/wake`
-- `202` for `/hooks/agent` (async run started)
+- `200` for `/hooks/agent` (async run accepted)
- `401` on auth failure
- `429` after repeated auth failures from the same client (check `Retry-After`)
- `400` on invalid payload
diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md
index 8c8267498b72..8654bb9795dd 100644
--- a/docs/channels/bluebubbles.md
+++ b/docs/channels/bluebubbles.md
@@ -48,6 +48,7 @@ Security note:
- Always set a webhook password.
- Webhook authentication is always required. OpenClaw rejects BlueBubbles webhook requests unless they include a password/guid that matches `channels.bluebubbles.password` (for example `?password=` or `x-password`), regardless of loopback/proxy topology.
+- Password authentication is checked before reading/parsing full webhook bodies.
## Keeping Messages.app alive (VM / headless setups)
diff --git a/docs/channels/broadcast-groups.md b/docs/channels/broadcast-groups.md
index 2d47d7c59431..cc55ebe6ce7c 100644
--- a/docs/channels/broadcast-groups.md
+++ b/docs/channels/broadcast-groups.md
@@ -439,4 +439,4 @@ Planned features:
- [Multi-Agent Configuration](/tools/multi-agent-sandbox-tools)
- [Routing Configuration](/channels/channel-routing)
-- [Session Management](/concepts/sessions)
+- [Session Management](/concepts/session)
diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md
index ac4480f69b28..f51f6c4147c5 100644
--- a/docs/channels/channel-routing.md
+++ b/docs/channels/channel-routing.md
@@ -41,6 +41,19 @@ Examples:
- `agent:main:telegram:group:-1001234567890:topic:42`
- `agent:main:discord:channel:123456:thread:987654`
+## Main DM route pinning
+
+When `session.dmScope` is `main`, direct messages may share one main session.
+To prevent the session’s `lastRoute` from being overwritten by non-owner DMs,
+OpenClaw infers a pinned owner from `allowFrom` when all of these are true:
+
+- `allowFrom` has exactly one non-wildcard entry.
+- The entry can be normalized to a concrete sender ID for that channel.
+- The inbound DM sender does not match that pinned owner.
+
+In that mismatch case, OpenClaw still records inbound session metadata, but it
+skips updating the main session `lastRoute`.
+
## Routing rules (how an agent is chosen)
Routing picks **one agent** for each inbound message:
diff --git a/docs/channels/discord.md b/docs/channels/discord.md
index ccf0d7dc282f..15a92fc5161a 100644
--- a/docs/channels/discord.md
+++ b/docs/channels/discord.md
@@ -944,6 +944,7 @@ Auto-join example:
Notes:
- `voice.tts` overrides `messages.tts` for voice playback only.
+- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it.
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md
index e20f3f06f883..3158599aa86f 100644
--- a/docs/channels/feishu.md
+++ b/docs/channels/feishu.md
@@ -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..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
{
diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md
index 8281d0fb0d24..09693589af77 100644
--- a/docs/channels/googlechat.md
+++ b/docs/channels/googlechat.md
@@ -139,6 +139,8 @@ Configure your tunnel's ingress rules to only route the webhook path:
## How it works
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer ` header.
+ - OpenClaw verifies bearer auth before reading/parsing full webhook bodies when the header is present.
+ - Google Workspace Add-on requests that carry `authorizationEventObject.systemIdToken` in the body are supported via a stricter pre-auth body budget.
2. OpenClaw verifies the token against the configured `audienceType` + `audience`:
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
- `audienceType: "project-number"` → audience is the Cloud project number.
diff --git a/docs/channels/index.md b/docs/channels/index.md
index ff827d20f45f..a81b7e397585 100644
--- a/docs/channels/index.md
+++ b/docs/channels/index.md
@@ -13,28 +13,28 @@ Text is supported everywhere; media and reactions vary by channel.
## Supported channels
-- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
-- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
+- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
-- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls.
-- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
-- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
-- [Signal](/channels/signal) — signal-cli; privacy-focused.
-- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups).
-- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
-- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
+- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls.
- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately).
-- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
+- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
+- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
+- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
+- [Signal](/channels/signal) — signal-cli; privacy-focused.
+- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
+- [Slack](/channels/slack) — Bolt SDK; workspace apps.
+- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
+- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
+- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately).
-- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes
diff --git a/docs/channels/line.md b/docs/channels/line.md
index b87cbd3f5fbf..50972d93d212 100644
--- a/docs/channels/line.md
+++ b/docs/channels/line.md
@@ -48,6 +48,10 @@ The gateway responds to LINE’s webhook verification (GET) and inbound events (
If you need a custom path, set `channels.line.webhookPath` or
`channels.line.accounts..webhookPath` and update the URL accordingly.
+Security note:
+
+- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
+
## Configure
Minimal config:
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index 880941edd9cd..d03530f30e90 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -230,23 +230,31 @@ curl "https://api.telegram.org/bot/getUpdates"
## Feature reference
-
- OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives.
+
+ OpenClaw can stream partial replies in real time:
+
+ - direct chats: Telegram native draft streaming via `sendMessageDraft`
+ - groups/topics: preview message + `editMessageText`
Requirement:
- - `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`)
+ - `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
- This works in direct chats and groups/topics.
+ Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
+
+ For text-only replies:
- For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message).
+ - DM: OpenClaw updates the draft in place (no extra preview message)
+ - group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
+ If native draft transport is unavailable/rejected, OpenClaw automatically falls back to `sendMessage` + `editMessageText`.
+
Telegram-only reasoning stream:
- `/reasoning stream` sends reasoning to the live preview while generating
@@ -751,7 +759,7 @@ Primary reference:
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
-- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
+- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
diff --git a/docs/channels/tlon.md b/docs/channels/tlon.md
index dbd2015c4ef5..f3e70c7152a7 100644
--- a/docs/channels/tlon.md
+++ b/docs/channels/tlon.md
@@ -11,8 +11,8 @@ Tlon is a decentralized messenger built on Urbit. OpenClaw connects to your Urbi
respond to DMs and group chat messages. Group replies require an @ mention by default and can
be further restricted via allowlists.
-Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
-(URL appended to caption). Reactions, polls, and native media uploads are not supported.
+Status: supported via plugin. DMs, group mentions, thread replies, rich text formatting, and
+image uploads are supported. Reactions and polls are not yet supported.
## Plugin required
@@ -50,27 +50,38 @@ Minimal config (single account):
ship: "~sampel-palnet",
url: "https://your-ship-host",
code: "lidlut-tabwed-pillex-ridrup",
+ ownerShip: "~your-main-ship", // recommended: your ship, always allowed
},
},
}
```
-Private/LAN ship URLs (advanced):
+## Private/LAN ships
-By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening).
-If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`),
+By default, OpenClaw blocks private/internal hostnames and IP ranges for SSRF protection.
+If your ship is running on a private network (localhost, LAN IP, or internal hostname),
you must explicitly opt in:
```json5
{
channels: {
tlon: {
+ url: "http://localhost:8080",
allowPrivateNetwork: true,
},
},
}
```
+This applies to URLs like:
+
+- `http://localhost:8080`
+- `http://192.168.x.x:8080`
+- `http://my-ship.local:8080`
+
+⚠️ Only enable this if you trust your local network. This setting disables SSRF protections
+for requests to your ship URL.
+
## Group channels
Auto-discovery is enabled by default. You can also pin channels manually:
@@ -99,7 +110,7 @@ Disable auto-discovery:
## Access control
-DM allowlist (empty = allow all):
+DM allowlist (empty = no DMs allowed, use `ownerShip` for approval flow):
```json5
{
@@ -134,6 +145,56 @@ Group authorization (restricted by default):
}
```
+## Owner and approval system
+
+Set an owner ship to receive approval requests when unauthorized users try to interact:
+
+```json5
+{
+ channels: {
+ tlon: {
+ ownerShip: "~your-main-ship",
+ },
+ },
+}
+```
+
+The owner ship is **automatically authorized everywhere** — DM invites are auto-accepted and
+channel messages are always allowed. You don't need to add the owner to `dmAllowlist` or
+`defaultAuthorizedShips`.
+
+When set, the owner receives DM notifications for:
+
+- DM requests from ships not in the allowlist
+- Mentions in channels without authorization
+- Group invite requests
+
+## Auto-accept settings
+
+Auto-accept DM invites (for ships in dmAllowlist):
+
+```json5
+{
+ channels: {
+ tlon: {
+ autoAcceptDmInvites: true,
+ },
+ },
+}
+```
+
+Auto-accept group invites:
+
+```json5
+{
+ channels: {
+ tlon: {
+ autoAcceptGroupInvites: true,
+ },
+ },
+}
+```
+
## Delivery targets (CLI/cron)
Use these with `openclaw message send` or cron delivery:
@@ -141,8 +202,75 @@ Use these with `openclaw message send` or cron delivery:
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
+## Bundled skill
+
+The Tlon plugin includes a bundled skill ([`@tloncorp/tlon-skill`](https://github.com/tloncorp/tlon-skill))
+that provides CLI access to Tlon operations:
+
+- **Contacts**: get/update profiles, list contacts
+- **Channels**: list, create, post messages, fetch history
+- **Groups**: list, create, manage members
+- **DMs**: send messages, react to messages
+- **Reactions**: add/remove emoji reactions to posts and DMs
+- **Settings**: manage plugin permissions via slash commands
+
+The skill is automatically available when the plugin is installed.
+
+## Capabilities
+
+| Feature | Status |
+| --------------- | --------------------------------------- |
+| Direct messages | ✅ Supported |
+| Groups/channels | ✅ Supported (mention-gated by default) |
+| Threads | ✅ Supported (auto-replies in thread) |
+| Rich text | ✅ Markdown converted to Tlon format |
+| Images | ✅ Uploaded to Tlon storage |
+| Reactions | ✅ Via [bundled skill](#bundled-skill) |
+| Polls | ❌ Not yet supported |
+| Native commands | ✅ Supported (owner-only by default) |
+
+## Troubleshooting
+
+Run this ladder first:
+
+```bash
+openclaw status
+openclaw gateway status
+openclaw logs --follow
+openclaw doctor
+```
+
+Common failures:
+
+- **DMs ignored**: sender not in `dmAllowlist` and no `ownerShip` configured for approval flow.
+- **Group messages ignored**: channel not discovered or sender not authorized.
+- **Connection errors**: check ship URL is reachable; enable `allowPrivateNetwork` for local ships.
+- **Auth errors**: verify login code is current (codes rotate).
+
+## Configuration reference
+
+Full configuration: [Configuration](/gateway/configuration)
+
+Provider options:
+
+- `channels.tlon.enabled`: enable/disable channel startup.
+- `channels.tlon.ship`: bot's Urbit ship name (e.g. `~sampel-palnet`).
+- `channels.tlon.url`: ship URL (e.g. `https://sampel-palnet.tlon.network`).
+- `channels.tlon.code`: ship login code.
+- `channels.tlon.allowPrivateNetwork`: allow localhost/LAN URLs (SSRF bypass).
+- `channels.tlon.ownerShip`: owner ship for approval system (always authorized).
+- `channels.tlon.dmAllowlist`: ships allowed to DM (empty = none).
+- `channels.tlon.autoAcceptDmInvites`: auto-accept DMs from allowlisted ships.
+- `channels.tlon.autoAcceptGroupInvites`: auto-accept all group invites.
+- `channels.tlon.autoDiscoverChannels`: auto-discover group channels (default: true).
+- `channels.tlon.groupChannels`: manually pinned channel nests.
+- `channels.tlon.defaultAuthorizedShips`: ships authorized for all channels.
+- `channels.tlon.authorization.channelRules`: per-channel auth rules.
+- `channels.tlon.showModelSignature`: append model name to messages.
+
## Notes
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
- Thread replies: if the inbound message is in a thread, OpenClaw replies in-thread.
-- Media: `sendMedia` falls back to text + URL (no native upload).
+- Rich text: Markdown formatting (bold, italic, code, headers, lists) is converted to Tlon's native format.
+- Images: URLs are uploaded to Tlon storage and embedded as image blocks.
diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md
index e93e71a6f7ea..4d40c2e9b4c8 100644
--- a/docs/channels/zalouser.md
+++ b/docs/channels/zalouser.md
@@ -1,5 +1,5 @@
---
-summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration"
+summary: "Zalo personal account support via native zca-js (QR login), capabilities, and configuration"
read_when:
- Setting up Zalo Personal for OpenClaw
- Debugging Zalo Personal login or message flow
@@ -8,7 +8,7 @@ title: "Zalo Personal"
# Zalo Personal (unofficial)
-Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`.
+Status: experimental. This integration automates a **personal Zalo account** via native `zca-js` inside OpenClaw.
> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk.
@@ -20,19 +20,14 @@ Zalo Personal ships as a plugin and is not bundled with the core install.
- Or from a source checkout: `openclaw plugins install ./extensions/zalouser`
- Details: [Plugins](/tools/plugin)
-## Prerequisite: zca-cli
-
-The Gateway machine must have the `zca` binary available in `PATH`.
-
-- Verify: `zca --version`
-- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs).
+No external `zca`/`openzca` CLI binary is required.
## Quick setup (beginner)
1. Install the plugin (see above).
2. Login (QR, on the Gateway machine):
- `openclaw channels login --channel zalouser`
- - Scan the QR code in the terminal with the Zalo mobile app.
+ - Scan the QR code with the Zalo mobile app.
3. Enable the channel:
```json5
@@ -51,8 +46,9 @@ The Gateway machine must have the `zca` binary available in `PATH`.
## What it is
-- Uses `zca listen` to receive inbound messages.
-- Uses `zca msg ...` to send replies (text/media/link).
+- Runs entirely in-process via `zca-js`.
+- Uses native event listeners to receive inbound messages.
+- Sends replies directly through the JS API (text/media/link).
- Designed for “personal account” use cases where Zalo Bot API is not available.
## Naming
@@ -77,7 +73,8 @@ openclaw directory groups list --channel zalouser --query "work"
## Access control (DMs)
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
-`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
+
+`channels.zalouser.allowFrom` accepts user IDs or names. During onboarding, names are resolved to IDs using the plugin's in-process contact lookup.
Approve via:
@@ -110,9 +107,31 @@ Example:
}
```
+### Group mention gating
+
+- `channels.zalouser.groups..requireMention` controls whether group replies require a mention.
+- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
+- This applies both to allowlisted groups and open group mode.
+
+Example:
+
+```json5
+{
+ channels: {
+ zalouser: {
+ groupPolicy: "allowlist",
+ groups: {
+ "*": { allow: true, requireMention: true },
+ "Work Chat": { allow: true, requireMention: false },
+ },
+ },
+ },
+}
+```
+
## Multi-account
-Accounts map to zca profiles. Example:
+Accounts map to `zalouser` profiles in OpenClaw state. Example:
```json5
{
@@ -128,13 +147,26 @@ Accounts map to zca profiles. Example:
}
```
-## Troubleshooting
+## Typing, reactions, and delivery acknowledgements
-**`zca` not found:**
+- OpenClaw sends a typing event before dispatching a reply (best-effort).
+- Message reaction action `react` is supported for `zalouser` in channel actions.
+ - Use `remove: true` to remove a specific reaction emoji from a message.
+ - Reaction semantics: [Reactions](/tools/reactions)
+- For inbound messages that include event metadata, OpenClaw sends delivered + seen acknowledgements (best-effort).
-- Install zca-cli and ensure it’s on `PATH` for the Gateway process.
+## Troubleshooting
-**Login doesn’t stick:**
+**Login doesn't stick:**
- `openclaw channels status --probe`
- Re-login: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser`
+
+**Allowlist/group name didn't resolve:**
+
+- Use numeric IDs in `allowFrom`/`groups`, or exact friend/group names.
+
+**Upgraded from old CLI-based setup:**
+
+- Remove any old external `zca` process assumptions.
+- The channel now runs fully in OpenClaw without external CLI binaries.
diff --git a/docs/ci.md b/docs/ci.md
index 51643c870017..16a7e6709645 100644
--- a/docs/ci.md
+++ b/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
diff --git a/docs/cli/config.md b/docs/cli/config.md
index 8bee6deec7a1..fa0d62e85110 100644
--- a/docs/cli/config.md
+++ b/docs/cli/config.md
@@ -1,5 +1,5 @@
---
-summary: "CLI reference for `openclaw config` (get/set/unset values and config file path)"
+summary: "CLI reference for `openclaw config` (get/set/unset/file/validate)"
read_when:
- You want to read or edit config non-interactively
title: "config"
@@ -7,8 +7,8 @@ title: "config"
# `openclaw config`
-Config helpers: get/set/unset values by path and print the active config file.
-Run without a subcommand to open
+Config helpers: get/set/unset/validate values by path and print the active
+config file. Run without a subcommand to open
the configure wizard (same as `openclaw configure`).
## Examples
@@ -20,6 +20,8 @@ openclaw config set browser.executablePath "/usr/bin/google-chrome"
openclaw config set agents.defaults.heartbeat.every "2h"
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
openclaw config unset tools.web.search.apiKey
+openclaw config validate
+openclaw config validate --json
```
## Paths
@@ -54,3 +56,13 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).
Restart the gateway after edits.
+
+## Validate
+
+Validate the current config against the active schema without starting the
+gateway.
+
+```bash
+openclaw config validate
+openclaw config validate --json
+```
diff --git a/docs/cli/index.md b/docs/cli/index.md
index ee916e4712cf..1c892d5d0eb8 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -380,7 +380,7 @@ Interactive configuration wizard (models, channels, skills, gateway).
### `config`
-Non-interactive config helpers (get/set/unset/file). Running `openclaw config` with no
+Non-interactive config helpers (get/set/unset/file/validate). Running `openclaw config` with no
subcommand launches the wizard.
Subcommands:
@@ -389,6 +389,8 @@ Subcommands:
- `config set `: set a value (JSON5 or raw string).
- `config unset `: remove a value.
- `config file`: print the active config file path.
+- `config validate`: validate the current config against the schema without starting the gateway.
+- `config validate --json`: emit machine-readable JSON output.
### `doctor`
@@ -826,7 +828,7 @@ Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `bas
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
-Preferred Anthropic auth (setup-token):
+Anthropic setup-token (supported):
```bash
claude setup-token
@@ -834,6 +836,10 @@ openclaw models auth setup-token --provider anthropic
openclaw models status
```
+Policy note: this is technical compatibility. Anthropic has blocked some
+subscription usage outside Claude Code in the past; verify current Anthropic
+terms before relying on setup-token in production.
+
### `models` (root)
`openclaw models` is an alias for `models status`.
diff --git a/docs/cli/memory.md b/docs/cli/memory.md
index 11b9926c56a7..7493df50382c 100644
--- a/docs/cli/memory.md
+++ b/docs/cli/memory.md
@@ -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.
diff --git a/docs/cli/models.md b/docs/cli/models.md
index 4147c6f27734..700b562c3532 100644
--- a/docs/cli/models.md
+++ b/docs/cli/models.md
@@ -77,3 +77,4 @@ Notes:
- `setup-token` prompts for a setup-token value (generate it with `claude setup-token` on any machine).
- `paste-token` accepts a token string generated elsewhere or from automation.
+- Anthropic policy note: setup-token support is technical compatibility. Anthropic has blocked some subscription usage outside Claude Code in the past, so verify current terms before using it broadly.
diff --git a/docs/cli/node.md b/docs/cli/node.md
index fb731cefedce..af07e61ba22b 100644
--- a/docs/cli/node.md
+++ b/docs/cli/node.md
@@ -92,12 +92,12 @@ Service commands accept `--json` for machine-readable output.
## Pairing
-The first connection creates a pending node pair request on the Gateway.
+The first connection creates a pending device pairing request (`role: node`) on the Gateway.
Approve it via:
```bash
-openclaw nodes pending
-openclaw nodes approve
+openclaw devices list
+openclaw devices approve
```
The node host stores its node id, token, display name, and gateway connection info in
diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md
index 7485499d1eaa..069c89082314 100644
--- a/docs/cli/onboard.md
+++ b/docs/cli/onboard.md
@@ -23,9 +23,12 @@ Interactive onboarding wizard (local or remote Gateway setup).
openclaw onboard
openclaw onboard --flow quickstart
openclaw onboard --flow manual
-openclaw onboard --mode remote --remote-url ws://gateway-host:18789
+openclaw onboard --mode remote --remote-url wss://gateway-host:18789
```
+For plaintext private-network `ws://` targets (trusted networks only), set
+`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment.
+
Non-interactive custom provider:
```bash
diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md
index 6f3cb103cfd9..0934a0289c62 100644
--- a/docs/cli/plugins.md
+++ b/docs/cli/plugins.md
@@ -48,6 +48,10 @@ Security note: treat plugin installs like running code. Prefer pinned versions.
Npm specs are **registry-only** (package name + optional version/tag). Git/URL/file
specs are rejected. Dependency installs run with `--ignore-scripts` for safety.
+If a bare install spec matches a bundled plugin id (for example `diffs`), OpenClaw
+installs the bundled plugin directly. To install an npm package with the same
+name, use an explicit scoped spec (for example `@scope/diffs`).
+
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
diff --git a/docs/cli/qr.md b/docs/cli/qr.md
index 109628264f68..98fbbcacfc94 100644
--- a/docs/cli/qr.md
+++ b/docs/cli/qr.md
@@ -34,6 +34,9 @@ openclaw qr --url wss://gateway.example/ws --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 `
diff --git a/docs/cli/secrets.md b/docs/cli/secrets.md
index 66e1c0e47693..db5e9476c558 100644
--- a/docs/cli/secrets.md
+++ b/docs/cli/secrets.md
@@ -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 `: 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.
diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md
index 20b2fffa319b..ff55f241bcd0 100644
--- a/docs/concepts/agent-workspace.md
+++ b/docs/concepts/agent-workspace.md
@@ -38,6 +38,8 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac
`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the
workspace and seed the bootstrap files if they are missing.
+Sandbox seed copies only accept regular in-workspace files; symlink/hardlink
+aliases that resolve outside the source workspace are ignored.
If you already manage the workspace files yourself, you can disable bootstrap
file creation:
diff --git a/docs/concepts/features.md b/docs/concepts/features.md
index 5eecd2153ef1..55f0b2bcd121 100644
--- a/docs/concepts/features.md
+++ b/docs/concepts/features.md
@@ -24,7 +24,7 @@ title: "Features"
Web Control UI and macOS companion app.
- iOS and Android nodes with Canvas support.
+ iOS and Android nodes with pairing, voice/chat, and rich device commands.
@@ -44,8 +44,8 @@ title: "Features"
- Media support for images, audio, and documents
- Optional voice note transcription hook
- WebChat and macOS menu bar app
-- iOS node with pairing and Canvas surface
-- Android node with pairing, Canvas, chat, and camera
+- iOS node with pairing, Canvas, camera, screen recording, location, and voice features
+- Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera/screen, plus device, notifications, contacts/calendar, motion, photos, SMS, and app update commands
Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only
diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md
index c8b2db0b091c..b39409452497 100644
--- a/docs/concepts/memory.md
+++ b/docs/concepts/memory.md
@@ -109,6 +109,8 @@ Defaults:
6. Otherwise memory search stays disabled until configured.
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
+- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
+ Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
Remote embeddings **require** an API key for the embedding provider. OpenClaw
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
@@ -116,7 +118,9 @@ variables. Codex OAuth only covers chat/completions and does **not** satisfy
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
`models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or
`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or
-`models.providers.mistral.apiKey`.
+`models.providers.mistral.apiKey`. Ollama typically does not require a real API
+key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by
+local policy).
When using a custom OpenAI-compatible endpoint,
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
@@ -331,7 +335,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
Fallbacks:
-- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`.
+- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
- The fallback provider is only used when the primary embedding provider fails.
Batch indexing (OpenAI + Gemini + Voyage):
diff --git a/docs/concepts/model-failover.md b/docs/concepts/model-failover.md
index 8e74ec3fecff..80b3420d07c1 100644
--- a/docs/concepts/model-failover.md
+++ b/docs/concepts/model-failover.md
@@ -83,6 +83,9 @@ When a profile fails due to auth/rate‑limit errors (or a timeout that looks
like rate limiting), OpenClaw marks it in cooldown and moves to the next profile.
Format/invalid‑request errors (for example Cloud Code Assist tool call ID
validation failures) are treated as failover‑worthy and use the same cooldowns.
+OpenAI-compatible stop-reason errors such as `Unhandled stop reason: error`,
+`stop reason: error`, and `reason: error` are classified as timeout/failover
+signals.
Cooldowns use exponential backoff:
diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md
index 90afcbd58105..c7f770d68341 100644
--- a/docs/concepts/model-providers.md
+++ b/docs/concepts/model-providers.md
@@ -60,6 +60,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
- Example model: `anthropic/claude-opus-4-6`
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
+- Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance.
+- Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth.
```json5
{
@@ -75,6 +77,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
+- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
```json5
{
@@ -121,7 +124,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
- Provider: `zai`
- Auth: `ZAI_API_KEY`
-- Example model: `zai/glm-4.7`
+- Example model: `zai/glm-5`
- CLI: `openclaw onboard --auth-choice zai-api-key`
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
@@ -213,14 +216,20 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
Kimi K2 model IDs:
-{/_moonshot-kimi-k2-model-refs:start_/ && null}
+
+
+{/_ moonshot-kimi-k2-model-refs:start _/ && null}
+
+
- `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo`
- {/_moonshot-kimi-k2-model-refs:end_/ && null}
+
+ {/_ moonshot-kimi-k2-model-refs:end _/ && null}
+
```json5
{
@@ -345,13 +354,13 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
- Provider: `synthetic`
- Auth: `SYNTHETIC_API_KEY`
-- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
+- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.5`
- CLI: `openclaw onboard --auth-choice synthetic-api-key`
```json5
{
agents: {
- defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } },
+ defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" } },
},
models: {
mode: "merge",
@@ -360,7 +369,7 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
baseUrl: "https://api.synthetic.new/anthropic",
apiKey: "${SYNTHETIC_API_KEY}",
api: "anthropic-messages",
- models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }],
+ models: [{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }],
},
},
},
@@ -434,8 +443,8 @@ Example (OpenAI‑compatible):
{
agents: {
defaults: {
- model: { primary: "lmstudio/minimax-m2.1-gs32" },
- models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
+ model: { primary: "lmstudio/minimax-m2.5-gs32" },
+ models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
},
},
models: {
@@ -446,8 +455,8 @@ Example (OpenAI‑compatible):
api: "openai-completions",
models: [
{
- id: "minimax-m2.1-gs32",
- name: "MiniMax M2.1",
+ id: "minimax-m2.5-gs32",
+ name: "MiniMax M2.5",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -471,6 +480,9 @@ Notes:
- `contextWindow: 200000`
- `maxTokens: 8192`
- Recommended: set explicit values that match your proxy/model limits.
+- For `api: "openai-completions"` on non-native endpoints (any non-empty `baseUrl` whose host is not `api.openai.com`), OpenClaw forces `compat.supportsDeveloperRole: false` to avoid provider 400 errors for unsupported `developer` roles.
+- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`).
+- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints.
## CLI examples
diff --git a/docs/concepts/models.md b/docs/concepts/models.md
index b4317273d5c8..981bd95086cc 100644
--- a/docs/concepts/models.md
+++ b/docs/concepts/models.md
@@ -28,10 +28,11 @@ Related:
- `agents.defaults.imageModel` is used **only when** the primary model can’t accept images.
- Per-agent defaults can override `agents.defaults.model` via `agents.list[].model` plus bindings (see [/concepts/multi-agent](/concepts/multi-agent)).
-## Quick model picks (anecdotal)
+## Quick model policy
-- **GLM**: a bit better for coding/tool calling.
-- **MiniMax**: better for writing and vibes.
+- Set your primary to the strongest latest-generation model available to you.
+- Use fallbacks for cost/latency-sensitive tasks and lower-stakes chat.
+- For tool-enabled agents or untrusted inputs, avoid older/weaker model tiers.
## Setup wizard (recommended)
@@ -42,8 +43,7 @@ openclaw onboard
```
It can set up model + auth for common providers, including **OpenAI Code (Codex)
-subscription** (OAuth) and **Anthropic** (API key recommended; `claude
-setup-token` also supported).
+subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`).
## Config keys (overview)
@@ -160,7 +160,9 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
(effective auth per provider).
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
-Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
+Auth choice is provider/account dependent. For always-on gateway hosts, API keys are usually the most predictable; subscription token flows are also supported.
+
+Example (Anthropic setup-token):
```bash
claude setup-token
diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md
index 741867f188f3..4766687ad51d 100644
--- a/docs/concepts/oauth.md
+++ b/docs/concepts/oauth.md
@@ -10,7 +10,9 @@ title: "OAuth"
# OAuth
-OpenClaw supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. This page explains:
+OpenClaw supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. Anthropic subscription use outside Claude Code has been restricted for some users in the past, so treat it as a user-choice risk and verify current Anthropic policy yourself. OpenAI Codex OAuth is explicitly supported for use in external tools like OpenClaw. This page explains:
+
+For Anthropic in production, API key auth is the safer recommended path over subscription setup-token auth.
- how the OAuth **token exchange** works (PKCE)
- where tokens are **stored** (and why)
@@ -54,6 +56,12 @@ For static secret refs and runtime snapshot activation behavior, see [Secrets Ma
## Anthropic setup-token (subscription auth)
+
+Anthropic setup-token support is technical compatibility, not a policy guarantee.
+Anthropic has blocked some subscription usage outside Claude Code in the past.
+Decide for yourself whether to use subscription auth, and verify Anthropic's current terms.
+
+
Run `claude setup-token` on any machine, then paste it into OpenClaw:
```bash
@@ -76,7 +84,7 @@ openclaw models status
OpenClaw’s interactive login flows are implemented in `@mariozechner/pi-ai` and wired into the wizards/commands.
-### Anthropic (Claude Pro/Max) setup-token
+### Anthropic setup-token
Flow shape:
@@ -88,6 +96,8 @@ The wizard path is `openclaw onboard` → auth choice `setup-token` (Anthropic).
### OpenAI Codex (ChatGPT OAuth)
+OpenAI Codex OAuth is explicitly supported for use outside the Codex CLI, including OpenClaw workflows.
+
Flow shape (PKCE):
1. generate PKCE verifier/challenge + random `state`
diff --git a/docs/concepts/session-tool.md b/docs/concepts/session-tool.md
index aa7b78607d43..90b48a7db535 100644
--- a/docs/concepts/session-tool.md
+++ b/docs/concepts/session-tool.md
@@ -157,6 +157,8 @@ Parameters:
- `mode?` (`run|session`; defaults to `run`, but defaults to `session` when `thread=true`; `mode="session"` requires `thread=true`)
- `cleanup?` (`delete|keep`, default `keep`)
- `sandbox?` (`inherit|require`, default `inherit`; `require` rejects spawn unless the target child runtime is sandboxed)
+- `attachments?` (optional array of inline files; subagent runtime only, ACP rejects). Each entry: `{ name, content, encoding?: "utf8" | "base64", mimeType? }`. Files are materialized into the child workspace at `.openclaw/attachments//`. Returns a receipt with sha256 per file.
+- `attachAs?` (optional; `{ mountPath? }` hint reserved for future mount implementations)
Allowlist:
diff --git a/docs/concepts/sessions.md b/docs/concepts/sessions.md
deleted file mode 100644
index 6bc0c8e3501a..000000000000
--- a/docs/concepts/sessions.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-summary: "Alias for session management docs"
-read_when:
- - You looked for docs/concepts/sessions.md; canonical doc lives in docs/concepts/session.md
-title: "Sessions"
----
-
-# Sessions
-
-Canonical session management docs live in [Session management](/concepts/session).
diff --git a/docs/concepts/streaming.md b/docs/concepts/streaming.md
index 310759deee96..382dc730ccc6 100644
--- a/docs/concepts/streaming.md
+++ b/docs/concepts/streaming.md
@@ -138,7 +138,7 @@ Legacy key migration:
Telegram:
-- Uses Bot API `sendMessage` + `editMessageText`.
+- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
- `/reasoning stream` can write reasoning to preview.
diff --git a/docs/design/kilo-gateway-integration.md b/docs/design/kilo-gateway-integration.md
index 596a77f13858..4f34e553c0fd 100644
--- a/docs/design/kilo-gateway-integration.md
+++ b/docs/design/kilo-gateway-integration.md
@@ -462,7 +462,7 @@ const needsNonImageSanitize =
"id": "anthropic/claude-opus-4.6",
"name": "Anthropic: Claude Opus 4.6"
},
- { "id": "minimax/minimax-m2.1:free", "name": "Minimax: Minimax M2.1" }
+ { "id": "minimax/minimax-m2.5:free", "name": "Minimax: Minimax M2.5" }
]
}
}
diff --git a/docs/docs.json b/docs/docs.json
index 4f29a77b1571..4dfbf73684d6 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -597,7 +597,7 @@
},
{
"source": "/sessions",
- "destination": "/concepts/sessions"
+ "destination": "/concepts/session"
},
{
"source": "/setup",
@@ -832,7 +832,6 @@
"group": "First steps",
"pages": [
"start/getting-started",
- "start/quickstart",
"start/onboarding-overview",
"start/wizard",
"start/onboarding"
@@ -899,25 +898,25 @@
{
"group": "Messaging platforms",
"pages": [
- "channels/whatsapp",
- "channels/telegram",
+ "channels/bluebubbles",
"channels/discord",
- "channels/irc",
- "channels/slack",
"channels/feishu",
"channels/googlechat",
- "channels/mattermost",
- "channels/signal",
"channels/imessage",
- "channels/bluebubbles",
- "channels/msteams",
- "channels/synology-chat",
+ "channels/irc",
"channels/line",
"channels/matrix",
+ "channels/mattermost",
+ "channels/msteams",
"channels/nextcloud-talk",
"channels/nostr",
+ "channels/signal",
+ "channels/synology-chat",
+ "channels/slack",
+ "channels/telegram",
"channels/tlon",
"channels/twitch",
+ "channels/whatsapp",
"channels/zalo",
"channels/zalouser"
]
@@ -960,7 +959,6 @@
"group": "Sessions and memory",
"pages": [
"concepts/session",
- "concepts/sessions",
"concepts/session-pruning",
"concepts/session-tool",
"concepts/memory",
@@ -992,20 +990,21 @@
{
"group": "Built-in tools",
"pages": [
+ "tools/apply-patch",
"brave-search",
"perplexity",
- "tools/lobster",
- "tools/llm-task",
"tools/diffs",
+ "tools/pdf",
+ "tools/elevated",
"tools/exec",
"tools/exec-approvals",
"tools/firecrawl",
+ "tools/llm-task",
+ "tools/lobster",
"tools/loop-detection",
- "tools/web",
- "tools/apply-patch",
- "tools/elevated",
+ "tools/reactions",
"tools/thinking",
- "tools/reactions"
+ "tools/web"
]
},
{
@@ -1097,8 +1096,7 @@
"group": "Providers",
"pages": [
"providers/anthropic",
- "providers/openai",
- "providers/openrouter",
+ "providers/bedrock",
"providers/cloudflare-ai-gateway",
"providers/claude-max-api-proxy",
"providers/deepgram",
@@ -1106,23 +1104,24 @@
"providers/huggingface",
"providers/kilocode",
"providers/litellm",
- "providers/bedrock",
- "providers/vercel-ai-gateway",
+ "providers/glm",
+ "providers/minimax",
"providers/moonshot",
"providers/mistral",
- "providers/minimax",
"providers/nvidia",
"providers/ollama",
+ "providers/openai",
"providers/opencode",
+ "providers/openrouter",
+ "providers/qianfan",
"providers/qwen",
+ "providers/synthetic",
"providers/together",
+ "providers/vercel-ai-gateway",
"providers/venice",
"providers/vllm",
"providers/xiaomi",
- "providers/glm",
- "providers/zai",
- "providers/synthetic",
- "providers/qianfan"
+ "providers/zai"
]
}
]
@@ -1323,6 +1322,7 @@
"pages": [
"reference/wizard",
"reference/token-use",
+ "reference/secretref-credential-surface",
"reference/prompt-caching",
"reference/api-usage-costs",
"reference/transcript-hygiene",
@@ -1432,7 +1432,6 @@
"group": "第一步",
"pages": [
"zh-CN/start/getting-started",
- "zh-CN/start/quickstart",
"zh-CN/start/wizard",
"zh-CN/start/onboarding"
]
@@ -1497,24 +1496,24 @@
{
"group": "消息平台",
"pages": [
- "zh-CN/channels/whatsapp",
- "zh-CN/channels/telegram",
- "zh-CN/channels/grammy",
+ "zh-CN/channels/bluebubbles",
"zh-CN/channels/discord",
- "zh-CN/channels/slack",
"zh-CN/channels/feishu",
+ "zh-CN/channels/grammy",
"zh-CN/channels/googlechat",
- "zh-CN/channels/mattermost",
- "zh-CN/channels/signal",
"zh-CN/channels/imessage",
- "zh-CN/channels/bluebubbles",
- "zh-CN/channels/nextcloud-talk",
- "zh-CN/channels/msteams",
"zh-CN/channels/line",
"zh-CN/channels/matrix",
+ "zh-CN/channels/mattermost",
+ "zh-CN/channels/msteams",
+ "zh-CN/channels/nextcloud-talk",
"zh-CN/channels/nostr",
+ "zh-CN/channels/signal",
+ "zh-CN/channels/slack",
+ "zh-CN/channels/telegram",
"zh-CN/channels/tlon",
"zh-CN/channels/twitch",
+ "zh-CN/channels/whatsapp",
"zh-CN/channels/zalo",
"zh-CN/channels/zalouser"
]
@@ -1557,7 +1556,6 @@
"group": "会话与记忆",
"pages": [
"zh-CN/concepts/session",
- "zh-CN/concepts/sessions",
"zh-CN/concepts/session-pruning",
"zh-CN/concepts/session-tool",
"zh-CN/concepts/memory",
@@ -1589,18 +1587,19 @@
{
"group": "内置工具",
"pages": [
+ "zh-CN/tools/apply-patch",
"zh-CN/brave-search",
"zh-CN/perplexity",
- "zh-CN/tools/lobster",
- "zh-CN/tools/llm-task",
+ "zh-CN/tools/diffs",
+ "zh-CN/tools/elevated",
"zh-CN/tools/exec",
"zh-CN/tools/exec-approvals",
"zh-CN/tools/firecrawl",
- "zh-CN/tools/web",
- "zh-CN/tools/apply-patch",
- "zh-CN/tools/elevated",
+ "zh-CN/tools/llm-task",
+ "zh-CN/tools/lobster",
+ "zh-CN/tools/reactions",
"zh-CN/tools/thinking",
- "zh-CN/tools/reactions"
+ "zh-CN/tools/web"
]
},
{
@@ -1690,24 +1689,24 @@
"group": "提供商",
"pages": [
"zh-CN/providers/anthropic",
- "zh-CN/providers/openai",
- "zh-CN/providers/openrouter",
"zh-CN/providers/bedrock",
- "zh-CN/providers/vercel-ai-gateway",
"zh-CN/providers/claude-max-api-proxy",
"zh-CN/providers/deepgram",
"zh-CN/providers/github-copilot",
+ "zh-CN/providers/glm",
"zh-CN/providers/moonshot",
"zh-CN/providers/minimax",
- "zh-CN/providers/ollama",
"zh-CN/providers/opencode",
+ "zh-CN/providers/ollama",
+ "zh-CN/providers/openai",
+ "zh-CN/providers/openrouter",
+ "zh-CN/providers/qianfan",
"zh-CN/providers/qwen",
+ "zh-CN/providers/synthetic",
"zh-CN/providers/venice",
+ "zh-CN/providers/vercel-ai-gateway",
"zh-CN/providers/xiaomi",
- "zh-CN/providers/glm",
- "zh-CN/providers/zai",
- "zh-CN/providers/synthetic",
- "zh-CN/providers/qianfan"
+ "zh-CN/providers/zai"
]
}
]
diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md
index 448789c9a6cd..a7b8d44c9cff 100644
--- a/docs/gateway/authentication.md
+++ b/docs/gateway/authentication.md
@@ -8,23 +8,26 @@ title: "Authentication"
# Authentication
-OpenClaw supports OAuth and API keys for model providers. For Anthropic
-accounts, we recommend using an **API key**. For Claude subscription access,
-use the long‑lived token created by `claude setup-token`.
+OpenClaw supports OAuth and API keys for model providers. For always-on gateway
+hosts, API keys are usually the most predictable option. Subscription/OAuth
+flows are also supported when they match your provider account model.
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
layout.
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
-## Recommended Anthropic setup (API key)
+## Recommended setup (API key, any provider)
-If you’re using Anthropic directly, use an API key.
+If you’re running a long-lived gateway, start with an API key for your chosen
+provider.
+For Anthropic specifically, API key auth is the safe path and is recommended
+over subscription setup-token auth.
-1. Create an API key in the Anthropic Console.
+1. Create an API key in your provider console.
2. Put it on the **gateway host** (the machine running `openclaw gateway`).
```bash
-export ANTHROPIC_API_KEY="..."
+export _API_KEY="..."
openclaw models status
```
@@ -33,7 +36,7 @@ openclaw models status
```bash
cat >> ~/.openclaw/.env <<'EOF'
-ANTHROPIC_API_KEY=...
+_API_KEY=...
EOF
```
@@ -52,8 +55,8 @@ See [Help](/help) for details on env inheritance (`env.shellEnv`,
## Anthropic: setup-token (subscription auth)
-For Anthropic, the recommended path is an **API key**. If you’re using a Claude
-subscription, the setup-token flow is also supported. Run it on the **gateway host**:
+If you’re using a Claude subscription, the setup-token flow is supported. Run
+it on the **gateway host**:
```bash
claude setup-token
@@ -79,6 +82,12 @@ This credential is only authorized for use with Claude Code and cannot be used f
…use an Anthropic API key instead.
+
+Anthropic setup-token support is technical compatibility only. Anthropic has blocked
+some subscription usage outside Claude Code in the past. Use it only if you decide
+the policy risk is acceptable, and verify Anthropic's current terms yourself.
+
+
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
```bash
@@ -164,5 +173,5 @@ is missing, rerun `claude setup-token` and paste the token again.
## Requirements
-- Claude Max or Pro subscription (for `claude setup-token`)
+- Anthropic subscription account (for `claude setup-token`)
- Claude Code CLI installed (`claude` command available)
diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md
index 0639dc36e926..9767f2db6743 100644
--- a/docs/gateway/configuration-examples.md
+++ b/docs/gateway/configuration-examples.md
@@ -527,7 +527,13 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
}
```
-### Anthropic subscription + API key, MiniMax fallback
+### Anthropic setup-token + API key, MiniMax fallback
+
+
+Anthropic setup-token usage outside Claude Code has been restricted for some
+users in the past. Treat this as user-choice risk and verify current Anthropic
+terms before depending on subscription auth.
+
```json5
{
@@ -560,7 +566,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
workspace: "~/.openclaw/workspace",
model: {
primary: "anthropic/claude-opus-4-6",
- fallbacks: ["minimax/MiniMax-M2.1"],
+ fallbacks: ["minimax/MiniMax-M2.5"],
},
},
}
@@ -597,7 +603,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
{
agent: {
workspace: "~/.openclaw/workspace",
- model: { primary: "lmstudio/minimax-m2.1-gs32" },
+ model: { primary: "lmstudio/minimax-m2.5-gs32" },
},
models: {
mode: "merge",
@@ -608,8 +614,8 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
api: "openai-responses",
models: [
{
- id: "minimax-m2.1-gs32",
- name: "MiniMax M2.1 GS32",
+ id: "minimax-m2.5-gs32",
+ name: "MiniMax M2.5 GS32",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md
index f345c4a0e7f3..fde4b395c190 100644
--- a/docs/gateway/configuration-reference.md
+++ b/docs/gateway/configuration-reference.md
@@ -825,16 +825,22 @@ Time format in system prompt. Default: `auto` (OS preference).
defaults: {
models: {
"anthropic/claude-opus-4-6": { alias: "opus" },
- "minimax/MiniMax-M2.1": { alias: "minimax" },
+ "minimax/MiniMax-M2.5": { alias: "minimax" },
},
model: {
primary: "anthropic/claude-opus-4-6",
- fallbacks: ["minimax/MiniMax-M2.1"],
+ fallbacks: ["minimax/MiniMax-M2.5"],
},
imageModel: {
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
fallbacks: ["openrouter/google/gemini-2.0-flash-vision:free"],
},
+ pdfModel: {
+ primary: "anthropic/claude-opus-4-6",
+ fallbacks: ["openai/gpt-5-mini"],
+ },
+ pdfMaxBytesMb: 10,
+ pdfMaxPages: 20,
thinkingDefault: "low",
verboseDefault: "off",
elevatedDefault: "on",
@@ -853,6 +859,11 @@ Time format in system prompt. Default: `auto` (OS preference).
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
- Used by the `image` tool path as its vision-model config.
- Also used as fallback routing when the selected/default model cannot accept image input.
+- `pdfModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
+ - Used by the `pdf` tool for model routing.
+ - If omitted, the PDF tool falls back to `imageModel`, then to best-effort provider defaults.
+- `pdfMaxBytesMb`: default PDF size limit for the `pdf` tool when `maxBytesMb` is not passed at call time.
+- `pdfMaxPages`: default maximum pages considered by extraction fallback mode in the `pdf` tool.
- `model.primary`: format `provider/model` (e.g. `anthropic/claude-opus-4-6`). If you omit the provider, OpenClaw assumes `anthropic` (deprecated).
- `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and `params` (provider-specific, for example `temperature`, `maxTokens`, `cacheRetention`, `context1m`).
- `params` merge precedence (config): `agents.defaults.models["provider/model"].params` is the base, then `agents.list[].params` (matching agent id) overrides by key.
@@ -874,6 +885,7 @@ Your configured aliases always win over defaults.
Z.AI GLM-4.x models automatically enable thinking mode unless you set `--thinking off` or define `agents.defaults.models["zai/"].params.thinking` yourself.
Z.AI models enable `tool_stream` by default for tool call streaming. Set `agents.defaults.models["zai/"].params.tool_stream` to `false` to disable it.
+Anthropic Claude 4.6 models default to `adaptive` thinking when no explicit thinking level is set.
### `agents.defaults.cliBackends`
@@ -1158,13 +1170,42 @@ 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.
- `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`).
- `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container.
+- Launch defaults are defined in `scripts/sandbox-browser-entrypoint.sh` and tuned for container hosts:
+ - `--remote-debugging-address=127.0.0.1`
+ - `--remote-debugging-port=`
+ - `--user-data-dir=${HOME}/.chrome`
+ - `--no-first-run`
+ - `--no-default-browser-check`
+ - `--disable-3d-apis`
+ - `--disable-gpu`
+ - `--disable-software-rasterizer`
+ - `--disable-dev-shm-usage`
+ - `--disable-background-networking`
+ - `--disable-features=TranslateUI`
+ - `--disable-breakpad`
+ - `--disable-crash-reporter`
+ - `--renderer-process-limit=2`
+ - `--no-zygote`
+ - `--metrics-recording-only`
+ - `--disable-extensions` (default enabled)
+ - `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu` are
+ enabled by default and can be disabled with
+ `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if WebGL/3D usage requires it.
+ - `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` re-enables extensions if your workflow
+ depends on them.
+ - `--renderer-process-limit=2` can be changed with
+ `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=`; set `0` to use Chromium's
+ default process limit.
+ - plus `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
+ - Defaults are the container image baseline; use a custom browser image with a custom
+ entrypoint to change container defaults.
@@ -1564,7 +1605,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.
---
@@ -1575,6 +1617,8 @@ Defaults for Talk mode (macOS/iOS/Android).
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
+Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved).
+
| Profile | Includes |
| ----------- | ----------------------------------------------------------------------------------------- |
| `minimal` | `session_status` only |
@@ -1761,7 +1805,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"`):
@@ -1774,7 +1818,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`.
@@ -1816,6 +1860,35 @@ Notes:
- `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`, visibility is forced to `tree` even if `tools.sessions.visibility="all"`.
+### `tools.sessions_spawn`
+
+Controls inline attachment support for `sessions_spawn`.
+
+```json5
+{
+ tools: {
+ sessions_spawn: {
+ attachments: {
+ enabled: false, // opt-in: set true to allow inline file attachments
+ maxTotalBytes: 5242880, // 5 MB total across all files
+ maxFiles: 50,
+ maxFileBytes: 1048576, // 1 MB per file
+ retainOnSessionKeep: false, // keep attachments when cleanup="keep"
+ },
+ },
+ },
+}
+```
+
+Notes:
+
+- Attachments are only supported for `runtime: "subagent"`. ACP runtime rejects them.
+- Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json`.
+- Attachment content is automatically redacted from transcript persistence.
+- Base64 inputs are validated with strict alphabet/padding checks and a pre-decode size guard.
+- File permissions are `0700` for directories and `0600` for files.
+- Cleanup follows the `cleanup` policy: `delete` always removes attachments; `keep` retains them only when `retainOnSessionKeep: true`.
+
### `tools.subagents`
```json5
@@ -1823,7 +1896,7 @@ Notes:
agents: {
defaults: {
subagents: {
- model: "minimax/MiniMax-M2.1",
+ model: "minimax/MiniMax-M2.5",
maxConcurrent: 1,
runTimeoutSeconds: 900,
archiveAfterMinutes: 60,
@@ -1889,6 +1962,7 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
- `models.providers.*.baseUrl`: upstream API base URL.
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
- `models.providers.*.models`: explicit provider model catalog entries.
+- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
- `models.bedrockDiscovery`: Bedrock auto-discovery settings root.
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
- `models.bedrockDiscovery.region`: AWS region for discovery.
@@ -2039,8 +2113,8 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
- model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
- models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
+ model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
+ models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
},
},
models: {
@@ -2052,8 +2126,8 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
api: "anthropic-messages",
models: [
{
- id: "hf:MiniMaxAI/MiniMax-M2.1",
- name: "MiniMax M2.1",
+ id: "hf:MiniMaxAI/MiniMax-M2.5",
+ name: "MiniMax M2.5",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -2071,15 +2145,15 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
-
+
```json5
{
agents: {
defaults: {
- model: { primary: "minimax/MiniMax-M2.1" },
+ model: { primary: "minimax/MiniMax-M2.5" },
models: {
- "minimax/MiniMax-M2.1": { alias: "Minimax" },
+ "minimax/MiniMax-M2.5": { alias: "Minimax" },
},
},
},
@@ -2092,8 +2166,8 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
api: "anthropic-messages",
models: [
{
- id: "MiniMax-M2.1",
- name: "MiniMax M2.1",
+ id: "MiniMax-M2.5",
+ name: "MiniMax M2.5",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
@@ -2113,7 +2187,7 @@ Set `MINIMAX_API_KEY`. Shortcut: `openclaw onboard --auth-choice minimax-api`.
-See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
+See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
@@ -2208,6 +2282,7 @@ See [Plugins](/tools/plugin).
color: "#FF4500",
// headless: false,
// noSandbox: false,
+ // extraArgs: [],
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
// attachOnly: false,
},
@@ -2222,6 +2297,8 @@ See [Plugins](/tools/plugin).
- Remote profiles are attach-only (start/stop/reset disabled).
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
+- `extraArgs` appends extra launch flags to local Chromium startup (for example
+ `--disable-gpu`, window sizing, or debug flags).
---
@@ -2315,6 +2392,7 @@ See [Plugins](/tools/plugin).
- `controlUi.allowedOrigins`: explicit browser-origin allowlist for Gateway WebSocket connects. Required when browser clients are expected from non-loopback origins.
- `controlUi.dangerouslyAllowHostHeaderOriginFallback`: dangerous mode that enables Host-header origin fallback for deployments that intentionally rely on Host-header origin policy.
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
+- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- Local gateway call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
@@ -2561,14 +2639,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..apiKey`
-- `skills.entries..apiKey`
-- `channels.googlechat.serviceAccount`
-- `channels.googlechat.serviceAccountRef`
-- `channels.googlechat.accounts..serviceAccount`
-- `channels.googlechat.accounts..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
@@ -2606,6 +2681,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.
---
@@ -2625,8 +2701,8 @@ Notes:
}
```
-- Per-agent auth profiles stored at `/auth-profiles.json`.
-- Auth profiles support value-level refs (`keyRef` for `api_key`, `tokenRef` for `token`).
+- Per-agent profiles are stored at `/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).
@@ -2655,6 +2731,26 @@ Notes:
---
+## CLI
+
+```json5
+{
+ cli: {
+ banner: {
+ taglineMode: "off", // random | default | off
+ },
+ },
+}
+```
+
+- `cli.banner.taglineMode` controls banner tagline style:
+ - `"random"` (default): rotating funny/seasonal taglines.
+ - `"default"`: fixed neutral tagline (`All your chats, one OpenClaw.`).
+ - `"off"`: no tagline text (banner title/version still shown).
+- To hide the entire banner (not just taglines), set env `OPENCLAW_HIDE_BANNER=1`.
+
+---
+
## Wizard
Metadata written by CLI wizards (`onboard`, `configure`, `doctor`):
@@ -2803,7 +2899,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.
---
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index 16e1deb253da..ece612d101df 100644
--- a/docs/gateway/configuration.md
+++ b/docs/gateway/configuration.md
@@ -291,6 +291,11 @@ When validation fails:
}
```
+ Security note:
+ - Treat all hook/webhook payload content as untrusted input.
+ - Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
+ - For hook-driven agents, prefer strong modern model tiers and strict tool policy (for example messaging-only plus sandboxing where possible).
+
See [full reference](/gateway/configuration-reference#hooks) for all mapping options and Gmail integration.
@@ -527,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).
See [Environment](/help/environment) for full precedence and sources.
diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md
index 3f7e13d41e60..8a07a827467f 100644
--- a/docs/gateway/local-models.md
+++ b/docs/gateway/local-models.md
@@ -11,18 +11,18 @@ title: "Local Models"
Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
-## Recommended: LM Studio + MiniMax M2.1 (Responses API, full-size)
+## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size)
-Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
+Best current local stack. Load MiniMax M2.5 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
```json5
{
agents: {
defaults: {
- model: { primary: "lmstudio/minimax-m2.1-gs32" },
+ model: { primary: "lmstudio/minimax-m2.5-gs32" },
models: {
"anthropic/claude-opus-4-6": { alias: "Opus" },
- "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" },
+ "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" },
},
},
},
@@ -35,8 +35,8 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
api: "openai-responses",
models: [
{
- id: "minimax-m2.1-gs32",
- name: "MiniMax M2.1 GS32",
+ id: "minimax-m2.5-gs32",
+ name: "MiniMax M2.5 GS32",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -53,7 +53,7 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
**Setup checklist**
- Install LM Studio: [https://lmstudio.ai](https://lmstudio.ai)
-- In LM Studio, download the **largest MiniMax M2.1 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
+- In LM Studio, download the **largest MiniMax M2.5 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
- Keep the model loaded; cold-load adds startup latency.
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
- For WhatsApp, stick to Responses API so only final text is sent.
@@ -68,11 +68,11 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
defaults: {
model: {
primary: "anthropic/claude-sonnet-4-5",
- fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-6"],
+ fallbacks: ["lmstudio/minimax-m2.5-gs32", "anthropic/claude-opus-4-6"],
},
models: {
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
- "lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
+ "lmstudio/minimax-m2.5-gs32": { alias: "MiniMax Local" },
"anthropic/claude-opus-4-6": { alias: "Opus" },
},
},
@@ -86,8 +86,8 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
api: "openai-responses",
models: [
{
- id: "minimax-m2.1-gs32",
- name: "MiniMax M2.1 GS32",
+ id: "minimax-m2.5-gs32",
+ name: "MiniMax M2.5 GS32",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md
index 68170fe2b888..ea99f57c488d 100644
--- a/docs/gateway/remote.md
+++ b/docs/gateway/remote.md
@@ -133,6 +133,8 @@ Runbook: [macOS remote access](/platforms/mac/remote).
Short version: **keep the Gateway loopback-only** unless you’re sure you need a bind.
- **Loopback + SSH/Tailscale Serve** is the safest default (no public exposure).
+- Plaintext `ws://` is loopback-only by default. For trusted private networks,
+ set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
- Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*` is unset.
diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md
index fc3807b66582..d62af2f4f7db 100644
--- a/docs/gateway/sandboxing.md
+++ b/docs/gateway/sandboxing.md
@@ -129,6 +129,16 @@ other runtimes), either bake a custom image or install via
`sandbox.docker.setupCommand` (requires network egress + writable root +
root user).
+If you want a more functional sandbox image with common tooling (for example
+`curl`, `jq`, `nodejs`, `python3`, `git`), build:
+
+```bash
+scripts/sandbox-common-setup.sh
+```
+
+Then set `agents.defaults.sandbox.docker.image` to
+`openclaw-sandbox-common:bookworm-slim`.
+
Sandboxed browser image:
```bash
@@ -138,6 +148,40 @@ scripts/sandbox-browser-setup.sh
By default, sandbox containers run with **no network**.
Override with `agents.defaults.sandbox.docker.network`.
+The bundled sandbox browser image also applies conservative Chromium startup defaults
+for containerized workloads. Current container defaults include:
+
+- `--remote-debugging-address=127.0.0.1`
+- `--remote-debugging-port=`
+- `--user-data-dir=${HOME}/.chrome`
+- `--no-first-run`
+- `--no-default-browser-check`
+- `--disable-3d-apis`
+- `--disable-gpu`
+- `--disable-dev-shm-usage`
+- `--disable-background-networking`
+- `--disable-extensions`
+- `--disable-features=TranslateUI`
+- `--disable-breakpad`
+- `--disable-crash-reporter`
+- `--disable-software-rasterizer`
+- `--no-zygote`
+- `--metrics-recording-only`
+- `--renderer-process-limit=2`
+- `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled.
+- The three graphics hardening flags (`--disable-3d-apis`,
+ `--disable-software-rasterizer`, `--disable-gpu`) are optional and are useful
+ when containers lack GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0`
+ if your workload requires WebGL or other 3D/browser features.
+- `--disable-extensions` is enabled by default and can be disabled with
+ `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows.
+- `--renderer-process-limit=2` is controlled by
+ `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=`, where `0` keeps Chromium's default.
+
+If you need a different runtime profile, use a custom browser image and provide
+your own entrypoint. For local (non-container) Chromium profiles, use
+`browser.extraArgs` to append additional startup flags.
+
Security defaults:
- `network: "host"` is blocked.
@@ -147,6 +191,11 @@ Security defaults:
Docker installs and the containerized gateway live here:
[Docker](/install/docker)
+For Docker gateway deployments, `docker-setup.sh` can bootstrap sandbox config.
+Set `OPENCLAW_SANDBOX=1` (or `true`/`yes`/`on`) to enable that path. You can
+override socket location with `OPENCLAW_DOCKER_SOCKET`. Full setup and env
+reference: [Docker](/install/docker#enable-agent-sandbox-for-docker-gateway-opt-in).
+
## setupCommand (one-time container setup)
`setupCommand` runs **once** after the sandbox container is created (not on every run).
diff --git a/docs/gateway/secrets-plan-contract.md b/docs/gateway/secrets-plan-contract.md
index d503d6cac82b..83ed10b06dde 100644
--- a/docs/gateway/secrets-plan-contract.md
+++ b/docs/gateway/secrets-plan-contract.md
@@ -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
+
+Plan targets are accepted for supported credential paths in:
+
+- [SecretRef Credential Surface](/reference/secretref-credential-surface)
+
+## Target type behavior
-| `target.type` | Allowed `target.path` shape | Optional id match rule |
-| ------------------------------------ | --------------------------------------------------------- | --------------------------------------------------- |
-| `models.providers.apiKey` | `models.providers..apiKey` | `providerId` must match `` when present |
-| `skills.entries.apiKey` | `skills.entries..apiKey` | n/a |
-| `channels.googlechat.serviceAccount` | `channels.googlechat.serviceAccount` | `accountId` must be empty/omitted |
-| `channels.googlechat.serviceAccount` | `channels.googlechat.accounts..serviceAccount` | `accountId` must match `` when present |
+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.
-
-## Ref-only auth profiles and implicit providers
-
-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`.
+No writes are committed for an invalid plan.
-Behavior:
+## Runtime and audit scope notes
-- 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)
diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md
index 9fdec280d611..066da56d3182 100644
--- a/docs/gateway/secrets.md
+++ b/docs/gateway/secrets.md
@@ -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..apiKey`
-- `skills.entries..apiKey`
-- `channels.googlechat.serviceAccount`
-- `channels.googlechat.serviceAccountRef`
-- `channels.googlechat.accounts..serviceAccount`
-- `channels.googlechat.accounts..serviceAccountRef`
+- [SecretRef Credential Surface](/reference/secretref-credential-surface)
-### `~/.openclaw/agents//agent/auth-profiles.json`
+Runtime-minted or rotating credentials and OAuth refresh material are intentionally excluded from read-only SecretRef resolution.
-- `profiles..keyRef` for `type: "api_key"`
-- `profiles..tokenRef` for `type: "token"`
+## Required behavior and precedence
-OAuth credential storage changes are out of scope.
+- 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.
-## Required behavior and precedence
+Warning and audit signals:
-- 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.
+- `SECRETS_REF_OVERRIDES_PLAINTEXT` (runtime warning)
+- `REF_SHADOWED` (audit finding when `auth-profiles.json` credentials take precedence over `openclaw.json` refs)
-Warning code:
+Google Chat compatibility behavior:
-- `SECRETS_REF_OVERRIDES_PLAINTEXT`
+- `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..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 `
-`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 `/.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)
diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md
index 7fba7c556fda..e4b0b209fa11 100644
--- a/docs/gateway/security/index.md
+++ b/docs/gateway/security/index.md
@@ -224,39 +224,40 @@ When the audit prints findings, treat this as a priority order:
High-signal `checkId` values you will most likely see in real deployments (not exhaustive):
-| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
-| -------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
-| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
-| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
-| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
-| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
-| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
-| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
-| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
-| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
-| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
-| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
-| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
-| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
-| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
-| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
-| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
-| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
-| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
-| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
-| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
-| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
-| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
-| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
-| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
-| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
-| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
-| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
-| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
-| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
-| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
-| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
-| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
+| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
+| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------- |
+| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
+| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
+| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
+| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
+| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
+| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
+| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
+| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
+| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
+| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
+| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
+| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
+| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
+| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
+| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
+| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
+| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
+| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
+| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
+| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
+| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
+| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
+| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
+| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
+| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
+| `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no |
+| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
+| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
+| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
+| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
+| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
+| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
## Control UI over HTTP
@@ -515,7 +516,7 @@ Even with strong system prompts, **prompt injection is not solved**. System prom
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
- Note: sandboxing is opt-in. If sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox, and host exec does not require approvals unless you set host=gateway and configure exec approvals.
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
-- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.6 (or the latest Opus) because it’s strong at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
+- **Model choice matters:** older/smaller/legacy models are significantly less robust against prompt injection and tool misuse. For tool-enabled agents, use the strongest latest-generation, instruction-hardened model available.
Red flags to treat as untrusted:
@@ -538,6 +539,11 @@ Guidance:
- Only enable temporarily for tightly scoped debugging.
- If enabled, isolate that agent (sandbox + minimal tools + dedicated session namespace).
+Hooks risk note:
+
+- Hook payloads are untrusted content, even when delivery comes from systems you control (mail/docs/web content can carry prompt injection).
+- Weak model tiers increase this risk. For hook-driven automation, prefer strong modern model tiers and keep tool policy tight (`tools.profile: "messaging"` or stricter), plus sandboxing where possible.
+
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via
@@ -561,10 +567,14 @@ tool calls. Reduce the blast radius by:
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
+
+For tool-enabled agents or agents that read untrusted content, prompt-injection risk with older/smaller models is often too high. Do not run those workloads on weak model tiers.
+
+
Recommendations:
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
-- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
+- **Do not use older/weaker/smaller tiers** for tool-enabled agents or untrusted inboxes; the prompt-injection risk is too high.
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
@@ -691,6 +701,8 @@ do **not** protect local WS access by themselves.
Local call paths can use `gateway.remote.*` as fallback when `gateway.auth.*`
is unset.
Optional: pin remote TLS with `gateway.remote.tlsFingerprint` when using `wss://`.
+Plaintext `ws://` is loopback-only by default. For trusted private-network
+paths, set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` on the client process as break-glass.
Local device pairing:
diff --git a/docs/help/faq.md b/docs/help/faq.md
index 10009ba1b7a8..d7737bc31a5d 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -30,6 +30,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How long does install and onboarding usually take?](#how-long-does-install-and-onboarding-usually-take)
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
- [Windows install says git not found or openclaw not recognized](#windows-install-says-git-not-found-or-openclaw-not-recognized)
+ - [Windows exec output shows garbled Chinese text what should I do](#windows-exec-output-shows-garbled-chinese-text-what-should-i-do)
- [The docs didn't answer my question - how do I get a better answer?](#the-docs-didnt-answer-my-question-how-do-i-get-a-better-answer)
- [How do I install OpenClaw on Linux?](#how-do-i-install-openclaw-on-linux)
- [How do I install OpenClaw on a VPS?](#how-do-i-install-openclaw-on-a-vps)
@@ -100,6 +101,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
+ - [How do I disable funny CLI taglines?](#how-do-i-disable-funny-cli-taglines)
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
- [config.apply wiped my config. How do I recover and avoid this?](#configapply-wiped-my-config-how-do-i-recover-and-avoid-this)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
@@ -146,7 +148,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Can I use GPT 5.2 for daily tasks and Codex 5.3 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-53-for-coding)
- [Why do I see "Model … is not allowed" and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- - [Why do I see "Unknown model: minimax/MiniMax-M2.1"?](#why-do-i-see-unknown-model-minimaxminimaxm21)
+ - [Why do I see "Unknown model: minimax/MiniMax-M2.5"?](#why-do-i-see-unknown-model-minimaxminimaxm25)
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
- [Are opus / sonnet / gpt built-in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
@@ -578,12 +580,40 @@ Two common Windows issues:
npm config get prefix
```
-- Ensure `\\bin` is on PATH (on most systems it is `%AppData%\\npm`).
+- Add that directory to your user PATH (no `\bin` suffix needed on Windows; on most systems it is `%AppData%\npm`).
- Close and reopen PowerShell after updating PATH.
If you want the smoothest Windows setup, use **WSL2** instead of native Windows.
Docs: [Windows](/platforms/windows).
+### Windows exec output shows garbled Chinese text what should I do
+
+This is usually a console code page mismatch on native Windows shells.
+
+Symptoms:
+
+- `system.run`/`exec` output renders Chinese as mojibake
+- The same command looks fine in another terminal profile
+
+Quick workaround in PowerShell:
+
+```powershell
+chcp 65001
+[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
+[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
+$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
+```
+
+Then restart the Gateway and retry your command:
+
+```powershell
+openclaw gateway restart
+```
+
+If you still reproduce this on latest OpenClaw, track/report it in:
+
+- [Issue #30640](https://github.com/openclaw/openclaw/issues/30640)
+
### The docs didn't answer my question how do I get a better answer
Use the **hackable (git) install** so you have the full source and docs locally, then ask
@@ -659,7 +689,7 @@ Docs: [Update](/cli/update), [Updating](/install/updating).
`openclaw onboard` is the recommended setup path. In **local mode** it walks you through:
-- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
+- **Model/auth setup** (provider OAuth/setup-token flows and API keys supported, plus local model options such as LM Studio)
- **Workspace** location + bootstrap files
- **Gateway settings** (bind/port/auth/tailscale)
- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage)
@@ -674,6 +704,10 @@ No. You can run OpenClaw with **API keys** (Anthropic/OpenAI/others) or with
**local-only models** so your data stays on your device. Subscriptions (Claude
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
+If you choose Anthropic subscription auth, decide for yourself whether to use it:
+Anthropic has blocked some subscription usage outside Claude Code in the past.
+OpenAI Codex OAuth is explicitly supported for external tools like OpenClaw.
+
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
[Local models](/gateway/local-models), [Models](/concepts/models).
@@ -683,9 +717,9 @@ Yes. You can authenticate with a **setup-token**
instead of an API key. This is the subscription path.
Claude Pro/Max subscriptions **do not include an API key**, so this is the
-correct approach for subscription accounts. Important: you must verify with
-Anthropic that this usage is allowed under their subscription policy and terms.
-If you want the most explicit, supported path, use an Anthropic API key.
+technical path for subscription accounts. But this is your decision: Anthropic
+has blocked some subscription usage outside Claude Code in the past.
+If you want the clearest and safest supported path for production, use an Anthropic API key.
### How does Anthropic setuptoken auth work
@@ -705,12 +739,15 @@ Copy the token it prints, then choose **Anthropic token (paste setup-token)** in
Yes - via **setup-token**. OpenClaw no longer reuses Claude Code CLI OAuth tokens; use a setup-token or an Anthropic API key. Generate the token anywhere and paste it on the gateway host. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
-Note: Claude subscription access is governed by Anthropic's terms. For production or multi-user workloads, API keys are usually the safer choice.
+Important: this is technical compatibility, not a policy guarantee. Anthropic
+has blocked some subscription usage outside Claude Code in the past.
+You need to decide whether to use it and verify Anthropic's current terms.
+For production or multi-user workloads, Anthropic API key auth is the safer, recommended choice.
### Why am I seeing HTTP 429 ratelimiterror from Anthropic
That means your **Anthropic quota/rate limit** is exhausted for the current window. If you
-use a **Claude subscription** (setup-token or Claude Code OAuth), wait for the window to
+use a **Claude subscription** (setup-token), wait for the window to
reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console
for usage/billing and raise limits as needed.
@@ -734,8 +771,9 @@ OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizar
### Do you support OpenAI subscription auth Codex OAuth
-Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**. The onboarding wizard
-can run the OAuth flow for you.
+Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**.
+OpenAI explicitly allows subscription OAuth usage in external tools/workflows
+like OpenClaw. The onboarding wizard can run the OAuth flow for you.
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
@@ -752,7 +790,7 @@ This stores OAuth tokens in auth profiles on the gateway host. Details: [Model p
### Is a local model OK for casual chats
-Usually no. OpenClaw needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
+Usually no. OpenClaw needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.5 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
### How do I keep hosted model traffic in a specific region
@@ -1261,12 +1299,13 @@ It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
resolves, then Voyage, then Mistral. If no remote key is available, memory
search stays disabled until you configure it. If you have a local model path
configured and present, OpenClaw
-prefers `local`.
+prefers `local`. Ollama is supported when you explicitly set
+`memorySearch.provider = "ollama"`.
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
-`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding
+`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, Ollama, or local** embedding
models - see [Memory](/concepts/memory) for the setup details.
### Does memory persist forever What are the limits
@@ -1429,6 +1468,25 @@ The Gateway watches the config and supports hot-reload:
- `gateway.reload.mode: "hybrid"` (default): hot-apply safe changes, restart for critical ones
- `hot`, `restart`, `off` are also supported
+### How do I disable funny CLI taglines
+
+Set `cli.banner.taglineMode` in config:
+
+```json5
+{
+ cli: {
+ banner: {
+ taglineMode: "off", // random | default | off
+ },
+ },
+}
+```
+
+- `off`: hides tagline text but keeps the banner title/version line.
+- `default`: uses `All your chats, one OpenClaw.` every time.
+- `random`: rotating funny/seasonal taglines (default behavior).
+- If you want no banner at all, set env `OPENCLAW_HIDE_BANNER=1`.
+
### How do I enable web search and web fetch
`web_fetch` works without an API key. `web_search` requires a Brave Search API
@@ -1527,8 +1585,8 @@ Typical setup:
5. Approve the node on the Gateway:
```bash
- openclaw nodes pending
- openclaw nodes approve
+ openclaw devices list
+ openclaw devices approve
```
No separate TCP bridge is required; nodes connect over the Gateway WebSocket.
@@ -1697,8 +1755,8 @@ Recommended setup:
3. **Approve the node** on the gateway:
```bash
- openclaw nodes pending
- openclaw nodes approve
+ openclaw devices list
+ openclaw devices approve
```
Docs: [Gateway protocol](/gateway/protocol), [Discovery](/gateway/discovery), [macOS remote mode](/platforms/mac/remote).
@@ -1999,12 +2057,11 @@ Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-6`)
### What model do you recommend
-**Recommended default:** `anthropic/claude-opus-4-6`.
-**Good alternative:** `anthropic/claude-sonnet-4-5`.
-**Reliable (less character):** `openai/gpt-5.2` - nearly as good as Opus, just less personality.
-**Budget:** `zai/glm-4.7`.
+**Recommended default:** use the strongest latest-generation model available in your provider stack.
+**For tool-enabled or untrusted-input agents:** prioritize model strength over cost.
+**For routine/low-stakes chat:** use cheaper fallback models and route by agent role.
-MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
+MiniMax M2.5 has its own docs: [MiniMax](/providers/minimax) and
[Local models](/gateway/local-models).
Rule of thumb: use the **best model you can afford** for high-stakes work, and a cheaper
@@ -2048,8 +2105,9 @@ Docs: [Models](/concepts/models), [Configure](/cli/configure), [Config](/cli/con
### What do OpenClaw, Flawd, and Krill use for models
-- **OpenClaw + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-6`) - see [Anthropic](/providers/anthropic).
-- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) - see [MiniMax](/providers/minimax).
+- These deployments can differ and may change over time; there is no fixed provider recommendation.
+- Check the current runtime setting on each gateway with `openclaw models status`.
+- For security-sensitive/tool-enabled agents, use the strongest latest-generation model available.
### How do I switch models on the fly without restarting
@@ -2116,7 +2174,7 @@ Model "provider/model" is not allowed. Use /model to list available models.
That error is returned **instead of** a normal reply. Fix: add the model to
`agents.defaults.models`, remove the allowlist, or pick a model from `/model list`.
-### Why do I see Unknown model minimaxMiniMaxM21
+### Why do I see Unknown model minimaxMiniMaxM25
This means the **provider isn't configured** (no MiniMax provider config or auth
profile was found), so the model can't be resolved. A fix for this detection is
@@ -2127,8 +2185,8 @@ Fix checklist:
1. Upgrade to **2026.1.12** (or run from source `main`), then restart the gateway.
2. Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
exists in env/auth profiles so the provider can be injected.
-3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.1` or
- `minimax/MiniMax-M2.1-lightning`.
+3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.5` or
+ `minimax/MiniMax-M2.5-highspeed` (legacy: `minimax/MiniMax-M2.5-Lightning`).
4. Run:
```bash
@@ -2151,9 +2209,9 @@ Fallbacks are for **errors**, not "hard tasks," so use `/model` or a separate ag
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
agents: {
defaults: {
- model: { primary: "minimax/MiniMax-M2.1" },
+ model: { primary: "minimax/MiniMax-M2.5" },
models: {
- "minimax/MiniMax-M2.1": { alias: "minimax" },
+ "minimax/MiniMax-M2.5": { alias: "minimax" },
"openai/gpt-5.2": { alias: "gpt" },
},
},
@@ -2231,8 +2289,8 @@ Z.AI (GLM models):
{
agents: {
defaults: {
- model: { primary: "zai/glm-4.7" },
- models: { "zai/glm-4.7": {} },
+ model: { primary: "zai/glm-5" },
+ models: { "zai/glm-5": {} },
},
},
env: { ZAI_API_KEY: "..." },
diff --git a/docs/help/testing.md b/docs/help/testing.md
index 214d52fe017d..efebd7e5a855 100644
--- a/docs/help/testing.md
+++ b/docs/help/testing.md
@@ -136,7 +136,7 @@ Live tests are split into two layers so we can isolate failures:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- Set `OPENCLAW_LIVE_MODELS=modern` (or `all`, alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
- How to select models:
- - `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.1, Grok 4)
+ - `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.5, Grok 4)
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,..."` (comma allowlist)
- How to select providers:
@@ -167,7 +167,7 @@ Live tests are split into two layers so we can isolate failures:
- How to enable:
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
- How to select models:
- - Default: modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.1, Grok 4)
+ - Default: modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.5, Grok 4)
- `OPENCLAW_LIVE_GATEWAY_MODELS=all` is an alias for the modern allowlist
- Or set `OPENCLAW_LIVE_GATEWAY_MODELS="provider/model"` (or comma list) to narrow
- How to select providers (avoid “OpenRouter everything”):
@@ -251,7 +251,7 @@ Narrow, explicit allowlists are fastest and least flaky:
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Tool calling across several providers:
- - `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,zai/glm-4.7,minimax/minimax-m2.1" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
+ - `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
- Google focus (Gemini API key + Antigravity):
- Gemini (API key): `OPENCLAW_LIVE_GATEWAY_MODELS="google/gemini-3-flash-preview" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
@@ -280,10 +280,10 @@ This is the “common models” run we expect to keep working:
- Google (Gemini API): `google/gemini-3-pro-preview` and `google/gemini-3-flash-preview` (avoid older Gemini 2.x models)
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
- Z.AI (GLM): `zai/glm-4.7`
-- MiniMax: `minimax/minimax-m2.1`
+- MiniMax: `minimax/minimax-m2.5`
Run gateway smoke with tools + image:
-`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.1" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
+`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
### Baseline: tool calling (Read + optional Exec)
@@ -293,7 +293,7 @@ Pick at least one per provider family:
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-5`)
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3-pro-preview`)
- Z.AI (GLM): `zai/glm-4.7`
-- MiniMax: `minimax/minimax-m2.1`
+- MiniMax: `minimax/minimax-m2.5`
Optional additional coverage (nice to have):
diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md
index 4b6e93afe3c0..c2cb1a4312bb 100644
--- a/docs/help/troubleshooting.md
+++ b/docs/help/troubleshooting.md
@@ -40,6 +40,31 @@ If you see:
`HTTP 429: rate_limit_error: Extra usage is required for long context requests`,
go to [/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context](/gateway/troubleshooting#anthropic-429-extra-usage-required-for-long-context).
+## Plugin install fails with missing openclaw extensions
+
+If install fails with `package.json missing openclaw.extensions`, the plugin package
+is using an old shape that OpenClaw no longer accepts.
+
+Fix in the plugin package:
+
+1. Add `openclaw.extensions` to `package.json`.
+2. Point entries at built runtime files (usually `./dist/index.js`).
+3. Republish the plugin and run `openclaw plugins install ` again.
+
+Example:
+
+```json
+{
+ "name": "@openclaw/my-plugin",
+ "version": "1.2.3",
+ "openclaw": {
+ "extensions": ["./dist/index.js"]
+ }
+}
+```
+
+Reference: [/tools/plugin#distribution-npm](/tools/plugin#distribution-npm)
+
## Decision tree
```mermaid
diff --git a/docs/images/feishu-verification-token.png b/docs/images/feishu-verification-token.png
new file mode 100644
index 000000000000..0d6d72d10409
Binary files /dev/null and b/docs/images/feishu-verification-token.png differ
diff --git a/docs/index.md b/docs/index.md
index 60c59bb7fa40..606ff4828e5c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
- **Open source**: MIT licensed, community-driven
-**What do you need?** Node 22+, an API key (Anthropic recommended), and 5 minutes.
+**What do you need?** Node 22+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
## How it works
@@ -89,7 +89,7 @@ The Gateway is the single source of truth for sessions, routing, and channel con
Browser dashboard for chat, config, sessions, and nodes.
- Pair iOS and Android nodes with Canvas support.
+ Pair iOS and Android nodes for Canvas, camera/screen, and voice-enabled workflows.
@@ -164,7 +164,7 @@ Example:
Channel-specific setup for WhatsApp, Telegram, Discord, and more.
- iOS and Android nodes with pairing and Canvas.
+ iOS and Android nodes with pairing, Canvas, camera/screen, and device actions.
Common fixes and troubleshooting entry point.
diff --git a/docs/install/docker.md b/docs/install/docker.md
index 5a39333033dd..8d376fb06a16 100644
--- a/docs/install/docker.md
+++ b/docs/install/docker.md
@@ -59,6 +59,18 @@ Optional env vars:
- `OPENCLAW_DOCKER_APT_PACKAGES` — install extra apt packages during build
- `OPENCLAW_EXTRA_MOUNTS` — add extra host bind mounts
- `OPENCLAW_HOME_VOLUME` — persist `/home/node` in a named volume
+- `OPENCLAW_SANDBOX` — opt in to Docker gateway sandbox bootstrap. Only explicit truthy values enable it: `1`, `true`, `yes`, `on`
+- `OPENCLAW_INSTALL_DOCKER_CLI` — build arg passthrough for local image builds (`1` installs Docker CLI in the image). `docker-setup.sh` sets this automatically when `OPENCLAW_SANDBOX=1` for local builds.
+- `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`)
+- `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network
+ `ws://` targets for CLI/onboarding client paths (default is loopback-only)
+- `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` — disable container browser hardening flags
+ `--disable-3d-apis`, `--disable-software-rasterizer`, `--disable-gpu` when you need
+ WebGL/3D compatibility.
+- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` — keep extensions enabled when browser
+ flows require them (default keeps extensions disabled in sandbox browser).
+- `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=` — set Chromium renderer process
+ limit; set to `0` to skip the flag and use Chromium default behavior.
After it finishes:
@@ -66,6 +78,38 @@ After it finishes:
- Paste the token into the Control UI (Settings → token).
- Need the URL again? Run `docker compose run --rm openclaw-cli dashboard --no-open`.
+### Enable agent sandbox for Docker gateway (opt-in)
+
+`docker-setup.sh` can also bootstrap `agents.defaults.sandbox.*` for Docker
+deployments.
+
+Enable with:
+
+```bash
+export OPENCLAW_SANDBOX=1
+./docker-setup.sh
+```
+
+Custom socket path (for example rootless Docker):
+
+```bash
+export OPENCLAW_SANDBOX=1
+export OPENCLAW_DOCKER_SOCKET=/run/user/1000/docker.sock
+./docker-setup.sh
+```
+
+Notes:
+
+- The script mounts `docker.sock` only after sandbox prerequisites pass.
+- If sandbox setup cannot be completed, the script resets
+ `agents.defaults.sandbox.mode` to `off` to avoid stale/broken sandbox config
+ on reruns.
+- If `Dockerfile.sandbox` is missing, the script prints a warning and continues;
+ build `openclaw-sandbox:bookworm-slim` with `scripts/sandbox-setup.sh` if
+ needed.
+- For non-local `OPENCLAW_IMAGE` values, the image must already contain Docker
+ CLI support for sandbox execution.
+
### Automation/CI (non-interactive, no TTY noise)
For scripts and CI, disable Compose pseudo-TTY allocation with `-T`:
@@ -403,6 +447,12 @@ curl -fsS http://127.0.0.1:18789/readyz
Aliases: `/health` and `/ready`.
+The Docker image includes a built-in `HEALTHCHECK` that pings `/healthz` in the
+background. In plain terms: Docker keeps checking if OpenClaw is still
+responsive. If checks keep failing, Docker marks the container as `unhealthy`,
+and orchestration systems (Docker Compose restart policy, Swarm, Kubernetes,
+etc.) can automatically restart or replace it.
+
Authenticated deep health snapshot (gateway + channels):
```bash
@@ -629,6 +679,38 @@ Notes:
- Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`.
- Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`).
- noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query).
+- Browser container startup defaults are conservative for shared/container workloads, including:
+ - `--remote-debugging-address=127.0.0.1`
+ - `--remote-debugging-port=`
+ - `--user-data-dir=${HOME}/.chrome`
+ - `--no-first-run`
+ - `--no-default-browser-check`
+ - `--disable-3d-apis`
+ - `--disable-software-rasterizer`
+ - `--disable-gpu`
+ - `--disable-dev-shm-usage`
+ - `--disable-background-networking`
+ - `--disable-features=TranslateUI`
+ - `--disable-breakpad`
+ - `--disable-crash-reporter`
+ - `--metrics-recording-only`
+ - `--renderer-process-limit=2`
+ - `--no-zygote`
+ - `--disable-extensions`
+ - If `agents.defaults.sandbox.browser.noSandbox` is set, `--no-sandbox` and
+ `--disable-setuid-sandbox` are also appended.
+ - The three graphics hardening flags above are optional. If your workload needs
+ WebGL/3D, set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` to run without
+ `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu`.
+ - Extension behavior is controlled by `--disable-extensions` and can be disabled
+ (enables extensions) via `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for
+ extension-dependent pages or extensions-heavy workflows.
+ - `--renderer-process-limit=2` is also configurable with
+ `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT`; set `0` to let Chromium choose its
+ default process limit when browser concurrency needs tuning.
+
+Defaults are applied by default in the bundled image. If you need different
+Chromium flags, use a custom browser image and provide your own entrypoint.
Use config:
diff --git a/docs/install/fly.md b/docs/install/fly.md
index 3b2ad9d92051..f70f7590ad01 100644
--- a/docs/install/fly.md
+++ b/docs/install/fly.md
@@ -15,7 +15,7 @@ read_when:
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
- Fly.io account (free tier works)
-- Model auth: Anthropic API key (or other provider keys)
+- Model auth: API key for your chosen model provider
- Channel credentials: Discord bot token, Telegram token, etc.
## Beginner quick path
diff --git a/docs/install/installer.md b/docs/install/installer.md
index 331943d0a33e..78334681ad47 100644
--- a/docs/install/installer.md
+++ b/docs/install/installer.md
@@ -384,7 +384,7 @@ Use non-interactive flags/env vars for predictable runs.
- Run `npm config get prefix`, append `\bin`, add that directory to user PATH, then reopen PowerShell.
+ Run `npm config get prefix` and add that directory to your user PATH (no `\bin` suffix needed on Windows), then reopen PowerShell.
diff --git a/docs/install/nix.md b/docs/install/nix.md
index 784ca24707aa..4f5823645b63 100644
--- a/docs/install/nix.md
+++ b/docs/install/nix.md
@@ -23,7 +23,7 @@ What I need you to do:
1. Check if Determinate Nix is installed (if not, install it)
2. Create a local flake at ~/code/openclaw-local using templates/agent-first/flake.nix
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
-4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
+4. Set up secrets (bot token, model provider API key) - plain files at ~/.secrets/ is fine
5. Fill in the template placeholders and run home-manager switch
6. Verify: launchd running, bot responds to messages
diff --git a/docs/nodes/audio.md b/docs/nodes/audio.md
index f86fa0ea718a..1be356103238 100644
--- a/docs/nodes/audio.md
+++ b/docs/nodes/audio.md
@@ -109,6 +109,23 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
}
```
+### Echo transcript to chat (opt-in)
+
+```json5
+{
+ tools: {
+ media: {
+ audio: {
+ enabled: true,
+ echoTranscript: true, // default is false
+ echoFormat: '📝 "{transcript}"', // optional, supports {transcript}
+ models: [{ provider: "openai", model: "gpt-4o-mini-transcribe" }],
+ },
+ },
+ },
+}
+```
+
## Notes & limits
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
@@ -117,12 +134,26 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
- Mistral setup details: [Mistral](/providers/mistral).
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`.
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried.
+- Tiny/empty audio files below 1024 bytes are skipped before provider/CLI transcription.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- OpenAI auto default is `gpt-4o-mini-transcribe`; set `model: "gpt-4o-transcribe"` for higher accuracy.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
- Transcript is available to templates as `{{Transcript}}`.
+- `tools.media.audio.echoTranscript` is off by default; enable it to send transcript confirmation back to the originating chat before agent processing.
+- `tools.media.audio.echoFormat` customizes the echo text (placeholder: `{transcript}`).
- CLI stdout is capped (5MB); keep CLI output concise.
+### Proxy environment support
+
+Provider-based audio transcription honors standard outbound proxy env vars:
+
+- `HTTPS_PROXY`
+- `HTTP_PROXY`
+- `https_proxy`
+- `http_proxy`
+
+If no proxy env vars are set, direct egress is used. If proxy config is malformed, OpenClaw logs a warning and falls back to direct fetch.
+
## Mention Detection in Groups
When `requireMention: true` is set for a group chat, OpenClaw now transcribes audio **before** checking for mentions. This allows voice notes to be processed even when they contain mentions.
@@ -139,11 +170,18 @@ When `requireMention: true` is set for a group chat, OpenClaw now transcribes au
- If transcription fails during preflight (timeout, API error, etc.), the message is processed based on text-only mention detection.
- This ensures that mixed messages (text + audio) are never incorrectly dropped.
+**Opt-out per Telegram group/topic:**
+
+- Set `channels.telegram.groups..disableAudioPreflight: true` to skip preflight transcript mention checks for that group.
+- Set `channels.telegram.groups..topics..disableAudioPreflight` to override per-topic (`true` to skip, `false` to force-enable).
+- Default is `false` (preflight enabled when mention-gated conditions match).
+
**Example:** A user sends a voice note saying "Hey @Claude, what's the weather?" in a Telegram group with `requireMention: true`. The voice note is transcribed, the mention is detected, and the agent replies.
## Gotchas
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
+- For `parakeet-mlx`, if you pass `--output-dir`, OpenClaw reads `/.txt` when `--output-format` is `txt` (or omitted); non-`txt` output formats fall back to stdout parsing.
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.
- Preflight transcription only processes the **first** audio attachment for mention detection. Additional audio is processed during the main media understanding phase.
diff --git a/docs/nodes/camera.md b/docs/nodes/camera.md
index 2be8025ffa06..a8e952d9cb29 100644
--- a/docs/nodes/camera.md
+++ b/docs/nodes/camera.md
@@ -1,7 +1,7 @@
---
-summary: "Camera capture (iOS node + macOS app) for agent use: photos (jpg) and short video clips (mp4)"
+summary: "Camera capture (iOS/Android nodes + macOS app) for agent use: photos (jpg) and short video clips (mp4)"
read_when:
- - Adding or modifying camera capture on iOS nodes or macOS
+ - Adding or modifying camera capture on iOS/Android nodes or macOS
- Extending agent-accessible MEDIA temp-file workflows
title: "Camera Capture"
---
diff --git a/docs/nodes/index.md b/docs/nodes/index.md
index 8d5e599419ca..c58cd247a6c6 100644
--- a/docs/nodes/index.md
+++ b/docs/nodes/index.md
@@ -1,5 +1,5 @@
---
-summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/system"
+summary: "Nodes: pairing, capabilities, permissions, and CLI helpers for canvas/camera/screen/device/notifications/system"
read_when:
- Pairing iOS/Android nodes to a gateway
- Using node canvas/camera for agent context
@@ -9,7 +9,7 @@ title: "Nodes"
# Nodes
-A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
+A **node** is a companion device (macOS/iOS/Android/headless) that connects to the Gateway **WebSocket** (same port as operators) with `role: "node"` and exposes a command surface (e.g. `canvas.*`, `camera.*`, `device.*`, `notifications.*`, `system.*`) via `node.invoke`. Protocol details: [Gateway protocol](/gateway/protocol).
Legacy transport: [Bridge protocol](/gateway/bridge-protocol) (TCP JSONL; deprecated/removed for current nodes).
@@ -96,9 +96,9 @@ openclaw node restart
On the gateway host:
```bash
-openclaw nodes pending
-openclaw nodes approve
-openclaw nodes list
+openclaw devices list
+openclaw devices approve
+openclaw nodes status
```
Naming options:
@@ -261,6 +261,33 @@ Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
+## Android device + personal data commands
+
+Android nodes can advertise additional command families when the corresponding capabilities are enabled.
+
+Available families:
+
+- `device.status`, `device.info`, `device.permissions`, `device.health`
+- `notifications.list`, `notifications.actions`
+- `photos.latest`
+- `contacts.search`, `contacts.add`
+- `calendar.events`, `calendar.add`
+- `motion.activity`, `motion.pedometer`
+- `app.update`
+
+Example invokes:
+
+```bash
+openclaw nodes invoke --node --command device.status --params '{}'
+openclaw nodes invoke --node --command notifications.list --params '{}'
+openclaw nodes invoke --node --command photos.latest --params '{"limit":1}'
+```
+
+Notes:
+
+- Motion commands are capability-gated by available sensors.
+- `app.update` is permission + policy gated by the node runtime.
+
## System commands (node host / mac node)
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
@@ -331,7 +358,7 @@ openclaw node run --host --port 18789
Notes:
-- Pairing is still required (the Gateway will show a node approval prompt).
+- Pairing is still required (the Gateway will show a device pairing prompt).
- The node host stores its node id, token, display name, and gateway connection info in `~/.openclaw/node.json`.
- Exec approvals are enforced locally via `~/.openclaw/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
diff --git a/docs/nodes/media-understanding.md b/docs/nodes/media-understanding.md
index 6b9c78dece9d..ad784f22e5bb 100644
--- a/docs/nodes/media-understanding.md
+++ b/docs/nodes/media-understanding.md
@@ -40,6 +40,7 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
+ - audio transcript echo controls (`echoTranscript`, default `false`; `echoFormat`)
- optional **per‑capability `models` list** (preferred before shared models)
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
- `scope` (optional gating by channel/chatType/session key)
@@ -57,6 +58,8 @@ If understanding fails or is disabled, **the reply flow continues** with the ori
},
audio: {
/* optional overrides */
+ echoTranscript: true,
+ echoFormat: '📝 "{transcript}"',
},
video: {
/* optional overrides */
@@ -123,6 +126,7 @@ Recommended defaults:
Rules:
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
+- Audio files smaller than **1024 bytes** are treated as empty/corrupt and skipped before provider/CLI transcription.
- If the model returns more than `maxChars`, output is trimmed.
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
- If `.enabled: true` but no models are configured, OpenClaw tries the
@@ -160,6 +164,20 @@ To disable auto-detection, set:
Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI is on `PATH` (we expand `~`), or set an explicit CLI model with a full command path.
+### Proxy environment support (provider models)
+
+When provider-based **audio** and **video** media understanding is enabled, OpenClaw
+honors standard outbound proxy environment variables for provider HTTP calls:
+
+- `HTTPS_PROXY`
+- `HTTP_PROXY`
+- `https_proxy`
+- `http_proxy`
+
+If no proxy env vars are set, media understanding uses direct egress.
+If the proxy value is malformed, OpenClaw logs a warning and falls back to direct
+fetch.
+
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
@@ -181,23 +199,13 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). |
| Video | Google (Gemini API) | Provider video understanding. |
-## Recommended providers
-
-**Image**
-
-- Prefer your active model if it supports images.
-- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-6`, `google/gemini-3-pro-preview`.
-
-**Audio**
-
-- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, `deepgram/nova-3`, or `mistral/voxtral-mini-latest`.
-- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
-- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
-
-**Video**
+## Model selection guidance
-- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
-- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
+- Prefer the strongest latest-generation model available for each media capability when quality and safety matter.
+- For tool-enabled agents handling untrusted inputs, avoid older/weaker media models.
+- Keep at least one fallback per capability for availability (quality model + faster/cheaper model).
+- CLI fallbacks (`whisper-cli`, `whisper`, `gemini`) are useful when provider APIs are unavailable.
+- `parakeet-mlx` note: with `--output-dir`, OpenClaw reads `/.txt` when output format is `txt` (or unspecified); non-`txt` formats fall back to stdout.
## Attachment policy
diff --git a/docs/nodes/voicewake.md b/docs/nodes/voicewake.md
index fe7e2aa6a05c..b188ffaff9d5 100644
--- a/docs/nodes/voicewake.md
+++ b/docs/nodes/voicewake.md
@@ -12,7 +12,8 @@ OpenClaw treats **wake words as a single global list** owned by the **Gateway**.
- There are **no per-node custom wake words**.
- **Any node/app UI may edit** the list; changes are persisted by the Gateway and broadcast to everyone.
-- Each device still keeps its own **Voice Wake enabled/disabled** toggle (local UX + permissions differ).
+- macOS and iOS keep local **Voice Wake enabled/disabled** toggles (local UX + permissions differ).
+- Android currently keeps Voice Wake off and uses a manual mic flow in the Voice tab.
## Storage (Gateway host)
@@ -61,5 +62,5 @@ Who receives it:
### Android node
-- Exposes a Wake Words editor in Settings.
-- Calls `voicewake.set` over the Gateway WS so edits sync everywhere.
+- Voice Wake is currently disabled in Android runtime/Settings.
+- Android voice uses manual mic capture in the Voice tab instead of wake-word triggers.
diff --git a/docs/platforms/android.md b/docs/platforms/android.md
index 39f5aa12ae0a..fe1683abdbfc 100644
--- a/docs/platforms/android.md
+++ b/docs/platforms/android.md
@@ -1,5 +1,5 @@
---
-summary: "Android app (node): connection runbook + Canvas/Chat/Camera"
+summary: "Android app (node): connection runbook + Connect/Chat/Voice/Canvas command surface"
read_when:
- Pairing or reconnecting the Android node
- Debugging Android gateway discovery or auth
@@ -13,7 +13,7 @@ title: "Android App"
- Role: companion node app (Android does not host the Gateway).
- Gateway required: yes (run it on macOS, Linux, or Windows via WSL2).
-- Install: [Getting Started](/start/getting-started) + [Pairing](/gateway/pairing).
+- Install: [Getting Started](/start/getting-started) + [Pairing](/channels/pairing).
- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
- Protocols: [Gateway protocol](/gateway/protocol) (nodes + control plane).
@@ -25,7 +25,7 @@ System control (launchd/systemd) lives on the Gateway host. See [Gateway](/gatew
Android node app ⇄ (mDNS/NSD + WebSocket) ⇄ **Gateway**
-Android connects directly to the Gateway WebSocket (default `ws://:18789`) and uses Gateway-owned pairing.
+Android connects directly to the Gateway WebSocket (default `ws://:18789`) and uses device pairing (`role: node`).
### Prerequisites
@@ -75,9 +75,9 @@ Details and example CoreDNS config: [Bonjour](/gateway/bonjour).
In the Android app:
- The app keeps its gateway connection alive via a **foreground service** (persistent notification).
-- Open **Settings**.
-- Under **Discovered Gateways**, select your gateway and hit **Connect**.
-- If mDNS is blocked, use **Advanced → Manual Gateway** (host + port) and **Connect (Manual)**.
+- Open the **Connect** tab.
+- Use **Setup Code** or **Manual** mode.
+- If discovery is blocked, use manual host/port (and TLS/token/password when required) in **Advanced controls**.
After the first successful pairing, Android auto-reconnects on launch:
@@ -89,11 +89,12 @@ After the first successful pairing, Android auto-reconnects on launch:
On the gateway machine:
```bash
-openclaw nodes pending
-openclaw nodes approve
+openclaw devices list
+openclaw devices approve
+openclaw devices reject
```
-Pairing details: [Gateway pairing](/gateway/pairing).
+Pairing details: [Pairing](/channels/pairing).
### 5) Verify the node is connected
@@ -111,13 +112,13 @@ Pairing details: [Gateway pairing](/gateway/pairing).
### 6) Chat + history
-The Android node’s Chat sheet uses the gateway’s **primary session key** (`main`), so history and replies are shared with WebChat and other clients:
+The Android Chat tab supports session selection (default `main`, plus other existing sessions):
- History: `chat.history`
- Send: `chat.send`
- Push updates (best-effort): `chat.subscribe` → `event:"chat"`
-### 7) Canvas + camera
+### 7) Canvas + screen + camera
#### Gateway Canvas Host (recommended for web content)
@@ -149,3 +150,20 @@ Camera commands (foreground only; permission-gated):
- `camera.clip` (mp4)
See [Camera node](/nodes/camera) for parameters and CLI helpers.
+
+Screen commands:
+
+- `screen.record` (mp4; foreground only)
+
+### 8) Voice + expanded Android command surface
+
+- Voice: Android uses a single mic on/off flow in the Voice tab with transcript capture and TTS playback (ElevenLabs when configured, system TTS fallback).
+- Voice wake/talk-mode toggles are currently removed from Android UX/runtime.
+- Additional Android command families (availability depends on device + permissions):
+ - `device.status`, `device.info`, `device.permissions`, `device.health`
+ - `notifications.list`, `notifications.actions`
+ - `photos.latest`
+ - `contacts.search`, `contacts.add`
+ - `calendar.events`, `calendar.add`
+ - `motion.activity`, `motion.pedometer`
+ - `app.update`
diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md
index e56f7e192a4e..0a2eb5abae58 100644
--- a/docs/platforms/ios.md
+++ b/docs/platforms/ios.md
@@ -38,8 +38,8 @@ openclaw gateway --port 18789
3. Approve the pairing request on the gateway host:
```bash
-openclaw nodes pending
-openclaw nodes approve
+openclaw devices list
+openclaw devices approve
```
4. Verify connection:
@@ -98,11 +98,11 @@ openclaw nodes invoke --node "iOS Node" --command canvas.snapshot --params '{"ma
- `NODE_BACKGROUND_UNAVAILABLE`: bring the iOS app to the foreground (canvas/camera/screen commands require it).
- `A2UI_HOST_NOT_CONFIGURED`: the Gateway did not advertise a canvas host URL; check `canvasHost` in [Gateway configuration](/gateway/configuration).
-- Pairing prompt never appears: run `openclaw nodes pending` and approve manually.
+- Pairing prompt never appears: run `openclaw devices list` and approve manually.
- Reconnect fails after reinstall: the Keychain pairing token was cleared; re-pair the node.
## Related docs
-- [Pairing](/gateway/pairing)
+- [Pairing](/channels/pairing)
- [Discovery](/gateway/discovery)
- [Bonjour](/gateway/bonjour)
diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md
index 68cfe6012610..a71e2e8fe5ed 100644
--- a/docs/platforms/mac/release.md
+++ b/docs/platforms/mac/release.md
@@ -37,16 +37,16 @@ Notes:
# APP_BUILD must be numeric + monotonic for Sparkle compare.
# Default is auto-derived from APP_VERSION when omitted.
BUNDLE_ID=ai.openclaw.mac \
-APP_VERSION=2026.3.1 \
+APP_VERSION=2026.3.2 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
-ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.1.zip
+ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.2.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
-scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.1.dmg
+scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.2.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -54,13 +54,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.1.dmg
# --apple-id "" --team-id "" --password ""
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=ai.openclaw.mac \
-APP_VERSION=2026.3.1 \
+APP_VERSION=2026.3.2 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
-ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.1.dSYM.zip
+ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.2.dSYM.zip
```
## Appcast entry
@@ -68,7 +68,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
-SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
+SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.2.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -76,7 +76,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
-- Upload `OpenClaw-2026.3.1.zip` (and `OpenClaw-2026.3.1.dSYM.zip`) to the GitHub release for tag `v2026.3.1`.
+- Upload `OpenClaw-2026.3.2.zip` (and `OpenClaw-2026.3.2.dSYM.zip`) to the GitHub release for tag `v2026.3.2`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md
index d1513148689f..3ab668ea01e8 100644
--- a/docs/platforms/windows.md
+++ b/docs/platforms/windows.md
@@ -55,6 +55,50 @@ Repair/migrate:
openclaw doctor
```
+## Gateway auto-start before Windows login
+
+For headless setups, ensure the full boot chain runs even when no one logs into
+Windows.
+
+### 1) Keep user services running without login
+
+Inside WSL:
+
+```bash
+sudo loginctl enable-linger "$(whoami)"
+```
+
+### 2) Install the OpenClaw gateway user service
+
+Inside WSL:
+
+```bash
+openclaw gateway install
+```
+
+### 3) Start WSL automatically at Windows boot
+
+In PowerShell as Administrator:
+
+```powershell
+schtasks /create /tn "WSL Boot" /tr "wsl.exe -d Ubuntu --exec /bin/true" /sc onstart /ru SYSTEM
+```
+
+Replace `Ubuntu` with your distro name from:
+
+```powershell
+wsl --list --verbose
+```
+
+### Verify startup chain
+
+After a reboot (before Windows sign-in), check from WSL:
+
+```bash
+systemctl --user is-enabled openclaw-gateway
+systemctl --user status openclaw-gateway --no-pager
+```
+
## Advanced: expose WSL services over LAN (portproxy)
WSL has its own virtual network. If another machine needs to reach a service
diff --git a/docs/plugins/zalouser.md b/docs/plugins/zalouser.md
index 4d7981db0f7b..9d84ae8e6dae 100644
--- a/docs/plugins/zalouser.md
+++ b/docs/plugins/zalouser.md
@@ -1,5 +1,5 @@
---
-summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)"
+summary: "Zalo Personal plugin: QR login + messaging via native zca-js (plugin install + channel config + tool)"
read_when:
- You want Zalo Personal (unofficial) support in OpenClaw
- You are configuring or developing the zalouser plugin
@@ -8,7 +8,7 @@ title: "Zalo Personal Plugin"
# Zalo Personal (plugin)
-Zalo Personal support for OpenClaw via a plugin, using `zca-cli` to automate a normal Zalo user account.
+Zalo Personal support for OpenClaw via a plugin, using native `zca-js` to automate a normal Zalo user account.
> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk.
@@ -22,6 +22,8 @@ This plugin runs **inside the Gateway process**.
If you use a remote Gateway, install/configure it on the **machine running the Gateway**, then restart the Gateway.
+No external `zca`/`openzca` CLI binary is required.
+
## Install
### Option A: install from npm
@@ -41,14 +43,6 @@ cd ./extensions/zalouser && pnpm install
Restart the Gateway afterwards.
-## Prerequisite: zca-cli
-
-The Gateway machine must have `zca` on `PATH`:
-
-```bash
-zca --version
-```
-
## Config
Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
@@ -79,3 +73,5 @@ openclaw directory peers list --channel zalouser --query "name"
Tool name: `zalouser`
Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
+
+Channel message actions also support `react` for message reactions.
diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md
index 69a0025d2f55..de9743152732 100644
--- a/docs/providers/anthropic.md
+++ b/docs/providers/anthropic.md
@@ -35,6 +35,15 @@ openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
}
```
+## Thinking defaults (Claude 4.6)
+
+- Anthropic Claude 4.6 models default to `adaptive` thinking in OpenClaw when no explicit thinking level is set.
+- You can override per-message (`/think:`) or in model params:
+ `agents.defaults.models["anthropic/"].params.thinking`.
+- Related Anthropic docs:
+ - [Adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking)
+ - [Extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking)
+
## Prompt caching (Anthropic API)
OpenClaw supports Anthropic's prompt caching feature. This is **API-only**; subscription auth does not honor cache settings.
diff --git a/docs/providers/claude-max-api-proxy.md b/docs/providers/claude-max-api-proxy.md
index 11b830710810..885ceb35a94e 100644
--- a/docs/providers/claude-max-api-proxy.md
+++ b/docs/providers/claude-max-api-proxy.md
@@ -1,9 +1,9 @@
---
-summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
+summary: "Community proxy to expose Claude subscription credentials as an OpenAI-compatible endpoint"
read_when:
- You want to use Claude Max subscription with OpenAI-compatible tools
- You want a local API server that wraps Claude Code CLI
- - You want to save money by using subscription instead of API keys
+ - You want to evaluate subscription-based vs API-key-based Anthropic access
title: "Claude Max API Proxy"
---
@@ -11,6 +11,12 @@ title: "Claude Max API Proxy"
**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
+
+This path is technical compatibility only. Anthropic has blocked some subscription
+usage outside Claude Code in the past. You must decide for yourself whether to use
+it and verify Anthropic's current terms before relying on it.
+
+
## Why Use This?
| Approach | Cost | Best For |
@@ -18,7 +24,7 @@ title: "Claude Max API Proxy"
| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
-If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
+If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy may reduce cost for some workflows. API keys remain the clearer policy path for production use.
## How It Works
diff --git a/docs/providers/index.md b/docs/providers/index.md
index e9908818ca71..a660fa2d3313 100644
--- a/docs/providers/index.md
+++ b/docs/providers/index.md
@@ -13,15 +13,6 @@ default model as `provider/model`.
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels).
-## Highlight: Venice (Venice AI)
-
-Venice is our recommended Venice AI setup for privacy-first inference with an option to use Opus for hard tasks.
-
-- Default: `venice/llama-3.3-70b`
-- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
-
-See [Venice AI](/providers/venice).
-
## Quick start
1. Authenticate with the provider (usually via `openclaw onboard`).
@@ -47,16 +38,28 @@ See [Venice AI](/providers/venice).
- [Mistral](/providers/mistral)
- [OpenCode Zen](/providers/opencode)
- [Amazon Bedrock](/providers/bedrock)
-- [Z.AI](/providers/zai)
-- [Xiaomi](/providers/xiaomi)
+- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
+- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [GLM models](/providers/glm)
-- [MiniMax](/providers/minimax)
-- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [Hugging Face (Inference)](/providers/huggingface)
+- [Kilocode](/providers/kilocode)
+- [LiteLLM (unified gateway)](/providers/litellm)
+- [MiniMax](/providers/minimax)
+- [Mistral](/providers/mistral)
+- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
+- [NVIDIA](/providers/nvidia)
- [Ollama (local models)](/providers/ollama)
-- [vLLM (local models)](/providers/vllm)
+- [OpenAI (API + Codex)](/providers/openai)
+- [OpenCode Zen](/providers/opencode)
+- [OpenRouter](/providers/openrouter)
- [Qianfan](/providers/qianfan)
-- [NVIDIA](/providers/nvidia)
+- [Qwen (OAuth)](/providers/qwen)
+- [Together AI](/providers/together)
+- [Vercel AI Gateway](/providers/vercel-ai-gateway)
+- [Venice (Venice AI, privacy-focused)](/providers/venice)
+- [vLLM (local models)](/providers/vllm)
+- [Xiaomi](/providers/xiaomi)
+- [Z.AI](/providers/zai)
## Transcription providers
@@ -64,7 +67,7 @@ See [Venice AI](/providers/venice).
## Community tools
-- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
+- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Community proxy for Claude subscription credentials (verify Anthropic policy/terms before use)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md
index 294388fbcc79..b03bb75213ee 100644
--- a/docs/providers/minimax.md
+++ b/docs/providers/minimax.md
@@ -1,5 +1,5 @@
---
-summary: "Use MiniMax M2.1 in OpenClaw"
+summary: "Use MiniMax M2.5 in OpenClaw"
read_when:
- You want MiniMax models in OpenClaw
- You need MiniMax setup guidance
@@ -8,15 +8,15 @@ title: "MiniMax"
# MiniMax
-MiniMax is an AI company that builds the **M2/M2.1** model family. The current
-coding-focused release is **MiniMax M2.1** (December 23, 2025), built for
+MiniMax is an AI company that builds the **M2/M2.5** model family. The current
+coding-focused release is **MiniMax M2.5** (December 23, 2025), built for
real-world complex tasks.
-Source: [MiniMax M2.1 release note](https://www.minimax.io/news/minimax-m21)
+Source: [MiniMax M2.5 release note](https://www.minimax.io/news/minimax-m25)
-## Model overview (M2.1)
+## Model overview (M2.5)
-MiniMax highlights these improvements in M2.1:
+MiniMax highlights these improvements in M2.5:
- Stronger **multi-language coding** (Rust, Java, Go, C++, Kotlin, Objective-C, TS/JS).
- Better **web/app development** and aesthetic output quality (including native mobile).
@@ -27,13 +27,12 @@ MiniMax highlights these improvements in M2.1:
Droid/Factory AI, Cline, Kilo Code, Roo Code, BlackBox).
- Higher-quality **dialogue and technical writing** outputs.
-## MiniMax M2.1 vs MiniMax M2.1 Lightning
+## MiniMax M2.5 vs MiniMax M2.5 Highspeed
-- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
-- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
-- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
- coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
- regular M2.1 back-end during traffic spikes.
+- **Speed:** `MiniMax-M2.5-highspeed` is the official fast tier in MiniMax docs.
+- **Cost:** MiniMax pricing lists the same input cost and a higher output cost for highspeed.
+- **Compatibility:** OpenClaw still accepts legacy `MiniMax-M2.5-Lightning` configs, but prefer
+ `MiniMax-M2.5-highspeed` for new setup.
## Choose a setup
@@ -56,7 +55,7 @@ You will be prompted to select an endpoint:
See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details.
-### MiniMax M2.1 (API key)
+### MiniMax M2.5 (API key)
**Best for:** hosted MiniMax with Anthropic-compatible API.
@@ -64,12 +63,12 @@ Configure via CLI:
- Run `openclaw configure`
- Select **Model/auth**
-- Choose **MiniMax M2.1**
+- Choose **MiniMax M2.5**
```json5
{
env: { MINIMAX_API_KEY: "sk-..." },
- agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
+ agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
models: {
mode: "merge",
providers: {
@@ -79,11 +78,20 @@ Configure via CLI:
api: "anthropic-messages",
models: [
{
- id: "MiniMax-M2.1",
- name: "MiniMax M2.1",
- reasoning: false,
+ id: "MiniMax-M2.5",
+ name: "MiniMax M2.5",
+ reasoning: true,
+ input: ["text"],
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
+ contextWindow: 200000,
+ maxTokens: 8192,
+ },
+ {
+ id: "MiniMax-M2.5-highspeed",
+ name: "MiniMax M2.5 Highspeed",
+ reasoning: true,
input: ["text"],
- cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
contextWindow: 200000,
maxTokens: 8192,
},
@@ -94,9 +102,10 @@ Configure via CLI:
}
```
-### MiniMax M2.1 as fallback (Opus primary)
+### MiniMax M2.5 as fallback (example)
-**Best for:** keep Opus 4.6 as primary, fail over to MiniMax M2.1.
+**Best for:** keep your strongest latest-generation model as primary, fail over to MiniMax M2.5.
+Example below uses Opus as a concrete primary; swap to your preferred latest-gen primary model.
```json5
{
@@ -104,12 +113,12 @@ Configure via CLI:
agents: {
defaults: {
models: {
- "anthropic/claude-opus-4-6": { alias: "opus" },
- "minimax/MiniMax-M2.1": { alias: "minimax" },
+ "anthropic/claude-opus-4-6": { alias: "primary" },
+ "minimax/MiniMax-M2.5": { alias: "minimax" },
},
model: {
primary: "anthropic/claude-opus-4-6",
- fallbacks: ["minimax/MiniMax-M2.1"],
+ fallbacks: ["minimax/MiniMax-M2.5"],
},
},
},
@@ -119,7 +128,7 @@ Configure via CLI:
### Optional: Local via LM Studio (manual)
**Best for:** local inference with LM Studio.
-We have seen strong results with MiniMax M2.1 on powerful hardware (e.g. a
+We have seen strong results with MiniMax M2.5 on powerful hardware (e.g. a
desktop/server) using LM Studio's local server.
Configure manually via `openclaw.json`:
@@ -128,8 +137,8 @@ Configure manually via `openclaw.json`:
{
agents: {
defaults: {
- model: { primary: "lmstudio/minimax-m2.1-gs32" },
- models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
+ model: { primary: "lmstudio/minimax-m2.5-gs32" },
+ models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
},
},
models: {
@@ -141,8 +150,8 @@ Configure manually via `openclaw.json`:
api: "openai-responses",
models: [
{
- id: "minimax-m2.1-gs32",
- name: "MiniMax M2.1 GS32",
+ id: "minimax-m2.5-gs32",
+ name: "MiniMax M2.5 GS32",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -162,7 +171,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
1. Run `openclaw configure`.
2. Select **Model/auth**.
-3. Choose **MiniMax M2.1**.
+3. Choose **MiniMax M2.5**.
4. Pick your default model when prompted.
## Configuration options
@@ -177,29 +186,31 @@ Use the interactive config wizard to set MiniMax without editing JSON:
## Notes
- Model refs are `minimax/`.
+- Recommended model IDs: `MiniMax-M2.5` and `MiniMax-M2.5-highspeed`.
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
- Update pricing values in `models.json` if you need exact cost tracking.
- Referral link for MiniMax Coding Plan (10% off): [https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
-- Use `openclaw models list` and `openclaw models set minimax/MiniMax-M2.1` to switch.
+- Use `openclaw models list` and `openclaw models set minimax/MiniMax-M2.5` to switch.
## Troubleshooting
-### “Unknown model: minimax/MiniMax-M2.1”
+### “Unknown model: minimax/MiniMax-M2.5”
This usually means the **MiniMax provider isn’t configured** (no provider entry
and no MiniMax auth profile/env key found). A fix for this detection is in
**2026.1.12** (unreleased at the time of writing). Fix by:
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
-- Running `openclaw configure` and selecting **MiniMax M2.1**, or
+- Running `openclaw configure` and selecting **MiniMax M2.5**, or
- Adding the `models.providers.minimax` block manually, or
- Setting `MINIMAX_API_KEY` (or a MiniMax auth profile) so the provider can be injected.
Make sure the model id is **case‑sensitive**:
-- `minimax/MiniMax-M2.1`
-- `minimax/MiniMax-M2.1-lightning`
+- `minimax/MiniMax-M2.5`
+- `minimax/MiniMax-M2.5-highspeed`
+- `minimax/MiniMax-M2.5-Lightning` (legacy)
Then recheck with:
diff --git a/docs/providers/models.md b/docs/providers/models.md
index 37229e31f69d..89a23d358f46 100644
--- a/docs/providers/models.md
+++ b/docs/providers/models.md
@@ -11,15 +11,6 @@ title: "Model Provider Quickstart"
OpenClaw can use many LLM providers. Pick one, authenticate, then set the default
model as `provider/model`.
-## Highlight: Venice (Venice AI)
-
-Venice is our recommended Venice AI setup for privacy-first inference with an option to use Opus for the hardest tasks.
-
-- Default: `venice/llama-3.3-70b`
-- Best overall: `venice/claude-opus-45` (Opus remains the strongest)
-
-See [Venice AI](/providers/venice).
-
## Quick start (two steps)
1. Authenticate with the provider (usually via `openclaw onboard`).
diff --git a/docs/providers/moonshot.md b/docs/providers/moonshot.md
index 0a46c906748c..3e8217bbe5b0 100644
--- a/docs/providers/moonshot.md
+++ b/docs/providers/moonshot.md
@@ -15,14 +15,20 @@ Kimi Coding with `kimi-coding/k2p5`.
Current Kimi K2 model IDs:
-{/_moonshot-kimi-k2-ids:start_/ && null}
+
+
+{/_ moonshot-kimi-k2-ids:start _/ && null}
+
+
- `kimi-k2.5`
- `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview`
- `kimi-k2-thinking`
- `kimi-k2-thinking-turbo`
- {/_moonshot-kimi-k2-ids:end_/ && null}
+
+ {/_ moonshot-kimi-k2-ids:end _/ && null}
+
```bash
openclaw onboard --auth-choice moonshot-api-key
@@ -140,3 +146,35 @@ Note: Moonshot and Kimi Coding are separate providers. Keys are not interchangea
- If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly.
- Use `https://api.moonshot.ai/v1` for the international endpoint, and `https://api.moonshot.cn/v1` for the China endpoint.
+
+## Native thinking mode (Moonshot)
+
+Moonshot Kimi supports binary native thinking:
+
+- `thinking: { type: "enabled" }`
+- `thinking: { type: "disabled" }`
+
+Configure it per model via `agents.defaults.models..params`:
+
+```json5
+{
+ agents: {
+ defaults: {
+ models: {
+ "moonshot/kimi-k2.5": {
+ params: {
+ thinking: { type: "disabled" },
+ },
+ },
+ },
+ },
+ },
+}
+```
+
+OpenClaw also maps runtime `/think` levels for Moonshot:
+
+- `/think off` -> `thinking.type=disabled`
+- any non-off thinking level -> `thinking.type=enabled`
+
+When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible `tool_choice` values to `auto` for compatibility.
diff --git a/docs/providers/openai.md b/docs/providers/openai.md
index 9eb167631c39..378381b2454f 100644
--- a/docs/providers/openai.md
+++ b/docs/providers/openai.md
@@ -10,6 +10,7 @@ title: "OpenAI"
OpenAI provides developer APIs for GPT models. Codex supports **ChatGPT sign-in** for subscription
access or **API key** sign-in for usage-based access. Codex cloud requires ChatGPT sign-in.
+OpenAI explicitly supports subscription OAuth usage in external tools/workflows like OpenClaw.
## Option A: OpenAI API key (OpenAI Platform)
@@ -29,7 +30,7 @@ openclaw onboard --openai-api-key "$OPENAI_API_KEY"
```json5
{
env: { OPENAI_API_KEY: "sk-..." },
- agents: { defaults: { model: { primary: "openai/gpt-5.1-codex" } } },
+ agents: { defaults: { model: { primary: "openai/gpt-5.2" } } },
}
```
@@ -71,6 +72,11 @@ You can set `agents.defaults.models..params.transport`:
For `openai/*` (Responses API), OpenClaw also enables WebSocket warm-up by
default (`openaiWsWarmup: true`) when WebSocket transport is used.
+Related OpenAI docs:
+
+- [Realtime API with WebSocket](https://platform.openai.com/docs/guides/realtime-websocket)
+- [Streaming API responses (SSE)](https://platform.openai.com/docs/guides/streaming-responses)
+
```json5
{
agents: {
@@ -100,7 +106,7 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for
agents: {
defaults: {
models: {
- "openai/gpt-5": {
+ "openai/gpt-5.2": {
params: {
openaiWsWarmup: false,
},
@@ -118,7 +124,7 @@ OpenAI docs describe warm-up as optional. OpenClaw enables it by default for
agents: {
defaults: {
models: {
- "openai/gpt-5": {
+ "openai/gpt-5.2": {
params: {
openaiWsWarmup: true,
},
@@ -151,7 +157,7 @@ Responses models (for example Azure OpenAI Responses):
agents: {
defaults: {
models: {
- "azure-openai-responses/gpt-4o": {
+ "azure-openai-responses/gpt-5.2": {
params: {
responsesServerCompaction: true,
},
@@ -169,7 +175,7 @@ Responses models (for example Azure OpenAI Responses):
agents: {
defaults: {
models: {
- "openai/gpt-5": {
+ "openai/gpt-5.2": {
params: {
responsesServerCompaction: true,
responsesCompactThreshold: 120000,
@@ -188,7 +194,7 @@ Responses models (for example Azure OpenAI Responses):
agents: {
defaults: {
models: {
- "openai/gpt-5": {
+ "openai/gpt-5.2": {
params: {
responsesServerCompaction: false,
},
diff --git a/docs/providers/synthetic.md b/docs/providers/synthetic.md
index cd9d81d04c87..ae406a0e3909 100644
--- a/docs/providers/synthetic.md
+++ b/docs/providers/synthetic.md
@@ -23,7 +23,7 @@ openclaw onboard --auth-choice synthetic-api-key
The default model is set to:
```
-synthetic/hf:MiniMaxAI/MiniMax-M2.1
+synthetic/hf:MiniMaxAI/MiniMax-M2.5
```
## Config example
@@ -33,8 +33,8 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
env: { SYNTHETIC_API_KEY: "sk-..." },
agents: {
defaults: {
- model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
- models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
+ model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
+ models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
},
},
models: {
@@ -46,8 +46,8 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
api: "anthropic-messages",
models: [
{
- id: "hf:MiniMaxAI/MiniMax-M2.1",
- name: "MiniMax M2.1",
+ id: "hf:MiniMaxAI/MiniMax-M2.5",
+ name: "MiniMax M2.5",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -71,7 +71,7 @@ All models below use cost `0` (input/output/cache).
| Model ID | Context window | Max tokens | Reasoning | Input |
| ------------------------------------------------------ | -------------- | ---------- | --------- | ------------ |
-| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
+| `hf:MiniMaxAI/MiniMax-M2.5` | 192000 | 65536 | false | text |
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
diff --git a/docs/providers/venice.md b/docs/providers/venice.md
index 4b7e55086653..6517e9909b24 100644
--- a/docs/providers/venice.md
+++ b/docs/providers/venice.md
@@ -86,8 +86,8 @@ openclaw agent --model venice/llama-3.3-70b --message "Hello, are you working?"
After setup, OpenClaw shows all available Venice models. Pick based on your needs:
-- **Default (our pick)**: `venice/llama-3.3-70b` for private, balanced performance.
-- **Best overall quality**: `venice/claude-opus-45` for hard jobs (Opus remains the strongest).
+- **Default model**: `venice/llama-3.3-70b` for private, balanced performance.
+- **High-capability option**: `venice/claude-opus-45` for hard jobs.
- **Privacy**: Choose "private" models for fully private inference.
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy.
@@ -112,16 +112,16 @@ openclaw models list | grep venice
## Which Model Should I Use?
-| Use Case | Recommended Model | Why |
-| ---------------------------- | -------------------------------- | ----------------------------------------- |
-| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
-| **Best overall quality** | `claude-opus-45` | Opus remains the strongest for hard tasks |
-| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
-| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
-| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
-| **Uncensored** | `venice-uncensored` | No content restrictions |
-| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
-| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
+| Use Case | Recommended Model | Why |
+| ---------------------------- | -------------------------------- | ----------------------------------- |
+| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
+| **High-capability option** | `claude-opus-45` | Higher quality for hard tasks |
+| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
+| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
+| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
+| **Uncensored** | `venice-uncensored` | No content restrictions |
+| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
+| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
## Available Models (25 Total)
@@ -158,7 +158,7 @@ openclaw models list | grep venice
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
-| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
+| `minimax-m21` | MiniMax M2.5 | 202k | Reasoning |
## Model Discovery
diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md
index 58fec7538fa2..a1002fc88ad8 100644
--- a/docs/reference/api-usage-costs.md
+++ b/docs/reference/api-usage-costs.md
@@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid
- `memorySearch.provider = "gemini"` → Gemini embeddings
- `memorySearch.provider = "voyage"` → Voyage embeddings
- `memorySearch.provider = "mistral"` → Mistral embeddings
+- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
- Optional fallback to a remote provider if local embeddings fail
You can keep it local with `memorySearch.provider = "local"` (no API usage).
diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md
new file mode 100644
index 000000000000..c8058b87b192
--- /dev/null
+++ b/docs/reference/secretref-credential-surface.md
@@ -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`)
+
+
+
+- `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"`)
+
+
+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:
+
+
+
+- `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`
+
+
+Rationale:
+
+- These credentials are minted, rotated, session-bearing, or OAuth-durable classes that do not fit read-only external SecretRef resolution.
diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json
new file mode 100644
index 000000000000..67f00caf4c19
--- /dev/null
+++ b/docs/reference/secretref-user-supplied-credentials-matrix.json
@@ -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
+ }
+ ]
+}
diff --git a/docs/reference/test.md b/docs/reference/test.md
index 49fcdb4814b5..8d99e674c3f1 100644
--- a/docs/reference/test.md
+++ b/docs/reference/test.md
@@ -12,6 +12,10 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 24+: OpenClaw auto-disables Vitest `vmForks` and uses `forks` to avoid `ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
+- `pnpm test`: runs the fast core unit lane by default for quick local feedback.
+- `pnpm test:channels`: runs channel-heavy suites.
+- `pnpm test:extensions`: runs extension/plugin suites.
+- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md
index 4f85e7e866d4..1f7d561b66ac 100644
--- a/docs/reference/wizard.md
+++ b/docs/reference/wizard.md
@@ -30,7 +30,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- Full reset (also removes workspace)
- - **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
+ - **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, then paste the token (you can name it; blank = default).
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
@@ -44,7 +44,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- - **MiniMax M2.1**: config is auto-written.
+ - **MiniMax M2.5**: config is auto-written.
- More detail: [MiniMax](/providers/minimax)
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic)
@@ -52,7 +52,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
- **Kimi Coding**: config is auto-written.
- More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
- **Skip**: no auth configured yet.
- - Pick a default model from detected options (or enter provider/model manually).
+ - Pick a default model from detected options (or enter provider/model manually). For best quality and lower prompt-injection risk, choose the strongest latest-generation model available in your provider stack.
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
- API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`).
- OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents//agent/auth-profiles.json` (API keys + OAuth).
@@ -245,6 +245,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
+- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
diff --git a/docs/start/hubs.md b/docs/start/hubs.md
index e02741716df0..cad1e41e1147 100644
--- a/docs/start/hubs.md
+++ b/docs/start/hubs.md
@@ -50,7 +50,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Multi-agent routing](/concepts/multi-agent)
- [Compaction](/concepts/compaction)
- [Sessions](/concepts/session)
-- [Sessions (alias)](/concepts/sessions)
- [Session pruning](/concepts/session-pruning)
- [Session tools](/concepts/session-tool)
- [Queue](/concepts/queue)
@@ -110,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)
diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md
index dfa058af545b..3a5c86c360e1 100644
--- a/docs/start/onboarding.md
+++ b/docs/start/onboarding.md
@@ -34,6 +34,8 @@ Security trust model:
- By default, OpenClaw is a personal agent: one trusted operator boundary.
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
+- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in.
+- If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing.
diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md
index 5019956a05cd..237b7f716045 100644
--- a/docs/start/wizard-cli-reference.md
+++ b/docs/start/wizard-cli-reference.md
@@ -116,7 +116,7 @@ What you set:
## Auth and model options
-
+
Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
@@ -163,7 +163,7 @@ What you set:
Prompts for account ID, gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway).
-
+
Config is auto-written.
More detail: [MiniMax](/providers/minimax).
@@ -236,6 +236,7 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
+- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index b0ea916f350f..76de92c552ac 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -50,6 +50,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Workspace default (or existing workspace)
- Gateway port **18789**
- Gateway auth **Token** (auto‑generated, even on loopback)
+ - Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved)
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
- Tailscale exposure **Off**
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
@@ -63,8 +64,9 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
**Local mode (default)** walks you through these steps:
-1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
+1. **Model/Auth** — choose any supported provider/auth flow (API key, OAuth, or setup-token), including Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
+ Security note: if this agent will run tools or process webhook/hooks content, prefer the strongest latest-generation model available and keep tool policy strict. Weaker/older tiers are easier to prompt-inject.
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.
diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md
index fe4827a266ec..d16bfc3868b1 100644
--- a/docs/tools/acp-agents.md
+++ b/docs/tools/acp-agents.md
@@ -75,7 +75,7 @@ Thread binding support is adapter-specific. If the active channel adapter does n
Required feature flags for thread-bound ACP:
- `acp.enabled=true`
-- `acp.dispatch.enabled=true`
+- `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch)
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
@@ -120,6 +120,19 @@ Interface details:
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
- `label` (optional): operator-facing label used in session/banner text.
+## Sandbox compatibility
+
+ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.
+
+Current limitations:
+
+- If the requester session is sandboxed, ACP spawns are blocked.
+ - Error: `Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.`
+- `sessions_spawn` with `runtime: "acp"` does not support `sandbox: "require"`.
+ - Error: `sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".`
+
+Use `runtime: "subagent"` when you need sandbox-enforced execution.
+
### From `/acp` command
Use `/acp spawn` for explicit operator control from chat when needed.
@@ -236,6 +249,7 @@ Current acpx built-in harness aliases:
- `codex`
- `opencode`
- `gemini`
+- `kimi`
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
@@ -249,10 +263,11 @@ Core ACP baseline:
{
acp: {
enabled: true,
+ // Optional. Default is true; set false to pause ACP dispatch while keeping /acp controls.
dispatch: { enabled: true },
backend: "acpx",
defaultAgent: "codex",
- allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
+ allowedAgents: ["pi", "claude", "codex", "opencode", "gemini", "kimi"],
maxConcurrentSessions: 8,
stream: {
coalesceIdleMs: 300,
@@ -298,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
```
@@ -316,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.
@@ -403,6 +418,8 @@ Restart the gateway after changing these values.
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
| `Only can rebind this thread.` | Another user owns thread binding. | Rebind as owner or use a different thread. |
| `Thread bindings are unavailable for .` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
+| `Sandboxed sessions cannot spawn ACP sessions ...` | ACP runtime is host-side; requester session is sandboxed. | Use `runtime="subagent"` from sandboxed sessions, or run ACP spawn from a non-sandboxed session. |
+| `sessions_spawn sandbox="require" is unsupported for runtime="acp" ...` | `sandbox="require"` requested for ACP runtime. | Use `runtime="subagent"` for required sandboxing, or use ACP with `sandbox="inherit"` from a non-sandboxed session. |
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
| `AcpRuntimeError: Permission prompt unavailable in non-interactive mode` | `permissionMode` blocks writes/exec in non-interactive ACP session. | Set `plugins.entries.acpx.config.permissionMode` to `approve-all` and restart gateway. See [Permission configuration](#permission-configuration). |
| ACP session fails early with little output | Permission prompts are blocked by `permissionMode`/`nonInteractivePermissions`. | Check gateway logs for `AcpRuntimeError`. For full permissions, set `permissionMode=approve-all`; for graceful degradation, set `nonInteractivePermissions=deny`. |
diff --git a/docs/tools/browser.md b/docs/tools/browser.md
index 13eaf3203f84..70c420b6c335 100644
--- a/docs/tools/browser.md
+++ b/docs/tools/browser.md
@@ -97,7 +97,7 @@ Notes:
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
-- Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser.
+- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
diff --git a/docs/tools/diffs.md b/docs/tools/diffs.md
index a1c97746e764..323374ac5a5e 100644
--- a/docs/tools/diffs.md
+++ b/docs/tools/diffs.md
@@ -1,24 +1,34 @@
---
title: "Diffs"
-summary: "Read-only diff viewer and PNG renderer for agents (optional plugin tool)"
-description: "Use the optional Diffs plugin to render before/after text or unified patches as a gateway-hosted diff view or a PNG."
+summary: "Read-only diff viewer and file renderer for agents (optional plugin tool)"
+description: "Use the optional Diffs plugin to render before and after text or unified patches as a gateway-hosted diff view, a file (PNG or PDF), or both."
read_when:
- You want agents to show code or markdown edits as diffs
- - You want a canvas-ready viewer URL or a rendered diff PNG
+ - You want a canvas-ready viewer URL or a rendered diff file
+ - You need controlled, temporary diff artifacts with secure defaults
---
# Diffs
-`diffs` is an **optional plugin tool** that renders a read-only diff from either:
+`diffs` is an optional plugin tool that turns change content into a read-only diff artifact for agents.
-- arbitrary `before` / `after` text
-- a unified patch
+It accepts either:
-The tool can produce:
+- `before` and `after` text
+- a unified `patch`
-- a gateway-hosted viewer URL for canvas use
-- a PNG image for message delivery
-- both outputs together
+It can return:
+
+- a gateway viewer URL for canvas presentation
+- a rendered file path (PNG or PDF) for message delivery
+- both outputs in one call
+
+## Quick start
+
+1. Enable the plugin.
+2. Call `diffs` with `mode: "view"` for canvas-first flows.
+3. Call `diffs` with `mode: "file"` for chat file delivery flows.
+4. Call `diffs` with `mode: "both"` when you need both artifacts.
## Enable the plugin
@@ -34,20 +44,18 @@ The tool can produce:
}
```
-## What agents get back
-
-- `mode: "view"` returns `details.viewerUrl` and `details.viewerPath`
-- `mode: "image"` returns `details.imagePath` only
-- `mode: "both"` returns the viewer details plus `details.imagePath`
-
-Typical agent patterns:
+## Typical agent workflow
-- open `details.viewerUrl` in canvas with `canvas present`
-- send `details.imagePath` with the `message` tool using `path` or `filePath`
+1. Agent calls `diffs`.
+2. Agent reads `details` fields.
+3. Agent either:
+ - opens `details.viewerUrl` with `canvas present`
+ - sends `details.filePath` with `message` using `path` or `filePath`
+ - does both
-## Tool inputs
+## Input examples
-Before/after input:
+Before and after:
```json
{
@@ -58,7 +66,7 @@ Before/after input:
}
```
-Patch input:
+Patch:
```json
{
@@ -67,16 +75,80 @@ Patch input:
}
```
-Useful options:
+## Tool input reference
+
+All fields are optional unless noted:
+
+- `before` (`string`): original text. Required with `after` when `patch` is omitted.
+- `after` (`string`): updated text. Required with `before` when `patch` is omitted.
+- `patch` (`string`): unified diff text. Mutually exclusive with `before` and `after`.
+- `path` (`string`): display filename for before and after mode.
+- `lang` (`string`): language override hint for before and after mode.
+- `title` (`string`): viewer title override.
+- `mode` (`"view" | "file" | "both"`): output mode. Defaults to plugin default `defaults.mode`.
+- `theme` (`"light" | "dark"`): viewer theme. Defaults to plugin default `defaults.theme`.
+- `layout` (`"unified" | "split"`): diff layout. Defaults to plugin default `defaults.layout`.
+- `expandUnchanged` (`boolean`): expand unchanged sections when full context is available. Per-call option only (not a plugin default key).
+- `fileFormat` (`"png" | "pdf"`): rendered file format. Defaults to plugin default `defaults.fileFormat`.
+- `fileQuality` (`"standard" | "hq" | "print"`): quality preset for PNG or PDF rendering.
+- `fileScale` (`number`): device scale override (`1`-`4`).
+- `fileMaxWidth` (`number`): max render width in CSS pixels (`640`-`2400`).
+- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
+- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
+
+Validation and limits:
+
+- `before` and `after` each max 512 KiB.
+- `patch` max 2 MiB.
+- `path` max 2048 bytes.
+- `lang` max 128 bytes.
+- `title` max 1024 bytes.
+- Patch complexity cap: max 128 files and 120000 total lines.
+- `patch` and `before` or `after` together are rejected.
+- Rendered file safety limits (apply to PNG and PDF):
+ - `fileQuality: "standard"`: max 8 MP (8,000,000 rendered pixels).
+ - `fileQuality: "hq"`: max 14 MP (14,000,000 rendered pixels).
+ - `fileQuality: "print"`: max 24 MP (24,000,000 rendered pixels).
+ - PDF also has a max of 50 pages.
+
+## Output details contract
+
+The tool returns structured metadata under `details`.
+
+Shared fields for modes that create a viewer:
+
+- `artifactId`
+- `viewerUrl`
+- `viewerPath`
+- `title`
+- `expiresAt`
+- `inputKind`
+- `fileCount`
+- `mode`
+
+File fields when PNG or PDF is rendered:
+
+- `filePath`
+- `path` (same value as `filePath`, for message tool compatibility)
+- `fileBytes`
+- `fileFormat`
+- `fileQuality`
+- `fileScale`
+- `fileMaxWidth`
+
+Mode behavior summary:
+
+- `mode: "view"`: viewer fields only.
+- `mode: "file"`: file fields only, no viewer artifact.
+- `mode: "both"`: viewer fields plus file fields. If file rendering fails, viewer still returns with `fileError`.
-- `mode`: `view`, `image`, or `both`
-- `layout`: `unified` or `split`
-- `theme`: `light` or `dark`
-- `expandUnchanged`: expand unchanged sections instead of collapsing them
-- `path`: display name for before/after input
-- `title`: explicit diff title
-- `ttlSeconds`: viewer artifact lifetime
-- `baseUrl`: override the gateway base URL used in the returned viewer link
+## Collapsed unchanged sections
+
+- The viewer can show rows like `N unmodified lines`.
+- Expand controls on those rows are conditional and not guaranteed for every input kind.
+- Expand controls appear when the rendered diff has expandable context data, which is typical for before and after input.
+- For many unified patch inputs, omitted context bodies are not available in the parsed patch hunks, so the row can appear without expand controls. This is expected behavior.
+- `expandUnchanged` applies only when expandable context exists.
## Plugin defaults
@@ -99,6 +171,10 @@ Set plugin-wide defaults in `~/.openclaw/openclaw.json`:
wordWrap: true,
background: true,
theme: "dark",
+ fileFormat: "png",
+ fileQuality: "standard",
+ fileScale: 2,
+ fileMaxWidth: 960,
mode: "both",
},
},
@@ -119,17 +195,161 @@ Supported defaults:
- `wordWrap`
- `background`
- `theme`
+- `fileFormat`
+- `fileQuality`
+- `fileScale`
+- `fileMaxWidth`
- `mode`
-Explicit tool parameters override the plugin defaults.
+Explicit tool parameters override these defaults.
+
+## Security config
+
+- `security.allowRemoteViewer` (`boolean`, default `false`)
+ - `false`: non-loopback requests to viewer routes are denied.
+ - `true`: remote viewers are allowed if tokenized path is valid.
+
+Example:
+
+```json5
+{
+ plugins: {
+ entries: {
+ diffs: {
+ enabled: true,
+ config: {
+ security: {
+ allowRemoteViewer: false,
+ },
+ },
+ },
+ },
+ },
+}
+```
+
+## Artifact lifecycle and storage
+
+- Artifacts are stored under the temp subfolder: `$TMPDIR/openclaw-diffs`.
+- Viewer artifact metadata contains:
+ - random artifact ID (20 hex chars)
+ - random token (48 hex chars)
+ - `createdAt` and `expiresAt`
+ - stored `viewer.html` path
+- Default viewer TTL is 30 minutes when not specified.
+- Maximum accepted viewer TTL is 6 hours.
+- Cleanup runs opportunistically after artifact creation.
+- Expired artifacts are deleted.
+- Fallback cleanup removes stale folders older than 24 hours when metadata is missing.
+
+## Viewer URL and network behavior
+
+Viewer route:
+
+- `/plugins/diffs/view/{artifactId}/{token}`
+
+Viewer assets:
+
+- `/plugins/diffs/assets/viewer.js`
+- `/plugins/diffs/assets/viewer-runtime.js`
+
+URL construction behavior:
+
+- If `baseUrl` is provided, it is used after strict validation.
+- Without `baseUrl`, viewer URL defaults to loopback `127.0.0.1`.
+- If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used.
+
+`baseUrl` rules:
+
+- Must be `http://` or `https://`.
+- Query and hash are rejected.
+- Origin plus optional base path is allowed.
+
+## Security model
+
+Viewer hardening:
+
+- Loopback-only by default.
+- Tokenized viewer paths with strict ID and token validation.
+- Viewer response CSP:
+ - `default-src 'none'`
+ - scripts and assets only from self
+ - no outbound `connect-src`
+- Remote miss throttling when remote access is enabled:
+ - 40 failures per 60 seconds
+ - 60 second lockout (`429 Too Many Requests`)
+
+File rendering hardening:
+
+- Screenshot browser request routing is deny-by-default.
+- Only local viewer assets from `http://127.0.0.1/plugins/diffs/assets/*` are allowed.
+- External network requests are blocked.
+
+## Browser requirements for file mode
+
+`mode: "file"` and `mode: "both"` need a Chromium-compatible browser.
+
+Resolution order:
+
+1. `browser.executablePath` in OpenClaw config.
+2. Environment variables:
+ - `OPENCLAW_BROWSER_EXECUTABLE_PATH`
+ - `BROWSER_EXECUTABLE_PATH`
+ - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`
+3. Platform command/path discovery fallback.
+
+Common failure text:
+
+- `Diff PNG/PDF rendering requires a Chromium-compatible browser...`
+
+Fix by installing Chrome, Chromium, Edge, or Brave, or setting one of the executable path options above.
+
+## Troubleshooting
+
+Input validation errors:
+
+- `Provide patch or both before and after text.`
+ - Include both `before` and `after`, or provide `patch`.
+- `Provide either patch or before/after input, not both.`
+ - Do not mix input modes.
+- `Invalid baseUrl: ...`
+ - Use `http(s)` origin with optional path, no query/hash.
+- `{field} exceeds maximum size (...)`
+ - Reduce payload size.
+- Large patch rejection
+ - Reduce patch file count or total lines.
+
+Viewer accessibility issues:
+
+- Viewer URL resolves to `127.0.0.1` by default.
+- For remote access scenarios, either:
+ - pass `baseUrl` per tool call, or
+ - use `gateway.bind=custom` and `gateway.customBindHost`
+- Enable `security.allowRemoteViewer` only when you intend external viewer access.
+
+Unmodified-lines row has no expand button:
+
+- This can happen for patch input when the patch does not carry expandable context.
+- This is expected and does not indicate a viewer failure.
+
+Artifact not found:
+
+- Artifact expired due TTL.
+- Token or path changed.
+- Cleanup removed stale data.
+
+## Operational guidance
+
+- Prefer `mode: "view"` for local interactive reviews in canvas.
+- Prefer `mode: "file"` for outbound chat channels that need an attachment.
+- Keep `allowRemoteViewer` disabled unless your deployment requires remote viewer URLs.
+- Set explicit short `ttlSeconds` for sensitive diffs.
+- Avoid sending secrets in diff input when not required.
+- If your channel compresses images aggressively (for example Telegram or WhatsApp), prefer PDF output (`fileFormat: "pdf"`).
-## Notes
+Diff rendering engine:
-- Viewer pages are hosted locally by the gateway under `/plugins/diffs/...`.
-- Viewer artifacts are ephemeral and stored locally.
-- `mode: "image"` uses a faster image-only render path and does not create a viewer URL.
-- PNG rendering requires a Chromium-compatible browser. If auto-detection is not enough, set `browser.executablePath`.
-- Diff rendering is powered by [Diffs](https://diffs.com).
+- Powered by [Diffs](https://diffs.com).
## Related docs
diff --git a/docs/tools/index.md b/docs/tools/index.md
index ab65287cbfb5..fdbc02508335 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -174,7 +174,7 @@ Optional plugin tools:
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
-- [Diffs](/tools/diffs): read-only diff viewer and PNG renderer for before/after text or unified patches.
+- [Diffs](/tools/diffs): read-only diff viewer and PNG or PDF file renderer for before/after text or unified patches.
## Tool inventory
@@ -397,6 +397,12 @@ Notes:
- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing).
- Uses the image model directly (independent of the main chat model).
+### `pdf`
+
+Analyze one or more PDF documents.
+
+For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf).
+
### `message`
Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
@@ -466,7 +472,7 @@ Core parameters:
- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
-- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`
+- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `attachments?`, `attachAs?`
- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
Notes:
@@ -486,6 +492,9 @@ Notes:
- Reply format includes `Status`, `Result`, and compact stats.
- `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback.
- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered).
+- `sessions_spawn` supports inline file attachments for subagent runtime only (ACP rejects them). Each attachment has `name`, `content`, and optional `encoding` (`utf8` or `base64`) and `mimeType`. Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json` metadata file. The tool returns a receipt with `count`, `totalBytes`, per file `sha256`, and `relDir`. Attachment content is automatically redacted from transcript persistence.
+ - Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`).
+ - `attachAs.mountPath` is a reserved hint for future mount implementations.
- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
- `sessions_send` runs a reply‑back ping‑pong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0–5).
- After the ping‑pong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
diff --git a/docs/tools/pdf.md b/docs/tools/pdf.md
new file mode 100644
index 000000000000..e0b901446939
--- /dev/null
+++ b/docs/tools/pdf.md
@@ -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"
+}
+```
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index 3dc575088eb9..90e1f461f4c3 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -90,6 +90,22 @@ Notes:
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- Edge TTS is not supported for telephony.
+For STT/transcription, plugins can call:
+
+```ts
+const { text } = await api.runtime.stt.transcribeAudioFile({
+ filePath: "/tmp/inbound-audio.ogg",
+ cfg: api.config,
+ // Optional when MIME cannot be inferred reliably:
+ mime: "audio/ogg",
+});
+```
+
+Notes:
+
+- Uses core media-understanding audio configuration (`tools.media.audio`) and provider fallback order.
+- Returns `{ text: undefined }` when no transcription output is produced (for example skipped/unsupported input).
+
## Discovery & precedence
OpenClaw scans, in order:
diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md
index 7a220c07645a..17f9cfbb7f92 100644
--- a/docs/tools/reactions.md
+++ b/docs/tools/reactions.md
@@ -19,4 +19,5 @@ Channel notes:
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
+- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.
- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md
index 2cf55b6b12b9..9a2fdc87ea63 100644
--- a/docs/tools/thinking.md
+++ b/docs/tools/thinking.md
@@ -10,23 +10,26 @@ title: "Thinking Levels"
## What it does
- Inline directive in any inbound body: `/t `, `/think:`, or `/thinking `.
-- Levels (aliases): `off | minimal | low | medium | high | xhigh` (GPT-5.2 + Codex models only)
+- Levels (aliases): `off | minimal | low | medium | high | xhigh | adaptive`
- minimal → “think”
- low → “think hard”
- medium → “think harder”
- high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
+ - adaptive → provider-managed adaptive reasoning budget (supported for Anthropic Claude 4.6 model family)
- `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`.
- `highest`, `max` map to `high`.
- Provider notes:
+ - Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
+ - Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
## Resolution order
1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message).
3. Global default (`agents.defaults.thinkingDefault` in config).
-4. Fallback: low for reasoning-capable models; off otherwise.
+4. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
## Setting a session default
diff --git a/docs/tools/web.md b/docs/tools/web.md
index dbd95eda1bb0..c452782cad86 100644
--- a/docs/tools/web.md
+++ b/docs/tools/web.md
@@ -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
@@ -36,6 +36,8 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
| **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` |
+| **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"
},
},
},
@@ -208,6 +211,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
diff --git a/docs/zh-CN/channels/broadcast-groups.md b/docs/zh-CN/channels/broadcast-groups.md
index fc76f38a0ce0..dc40c90e2ffe 100644
--- a/docs/zh-CN/channels/broadcast-groups.md
+++ b/docs/zh-CN/channels/broadcast-groups.md
@@ -446,4 +446,4 @@ interface OpenClawConfig {
- [多智能体配置](/tools/multi-agent-sandbox-tools)
- [路由配置](/channels/channel-routing)
-- [会话管理](/concepts/sessions)
+- [会话管理](/concepts/session)
diff --git a/docs/zh-CN/channels/feishu.md b/docs/zh-CN/channels/feishu.md
index ff569c20e2f7..4cc8b578a6a4 100644
--- a/docs/zh-CN/channels/feishu.md
+++ b/docs/zh-CN/channels/feishu.md
@@ -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..requireMention`):
@@ -321,14 +362,36 @@ openclaw pairing approve feishu <配对码>
}
```
-### 仅允许特定用户在群组中使用
+### 仅允许特定群组
+
+```json5
+{
+ channels: {
+ feishu: {
+ groupPolicy: "allowlist",
+ // 群组 ID 格式为 oc_xxx
+ groupAllowFrom: ["oc_xxx", "oc_yyy"],
+ },
+ },
+}
+```
+
+### 仅允许特定成员在群组中发信(发送者白名单)
+
+除群组白名单外,该群组内**所有消息**均按发送者 open_id 校验:仅 `groups..allowFrom` 中列出的用户消息会被处理,其他成员的消息会被忽略(此为发送者级白名单,不仅针对 /reset、/new 等控制命令)。
```json5
{
channels: {
feishu: {
groupPolicy: "allowlist",
- groupAllowFrom: ["ou_xxx", "ou_yyy"],
+ 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..appId` | 应用 App ID | - |
-| `channels.feishu.accounts..appSecret` | 应用 App Secret | - |
-| `channels.feishu.accounts..domain` | 单账号 API 域名覆盖 | `feishu` |
-| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
-| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - |
-| `channels.feishu.groupPolicy` | 群组策略 | `open` |
-| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
-| `channels.feishu.groups..requireMention` | 是否需要 @提及 | `true` |
-| `channels.feishu.groups..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..appId` | 应用 App ID | - |
+| `channels.feishu.accounts..appSecret` | 应用 App Secret | - |
+| `channels.feishu.accounts..domain` | 单账号 API 域名覆盖 | `feishu` |
+| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
+| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - |
+| `channels.feishu.groupPolicy` | 群组策略 | `open` |
+| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
+| `channels.feishu.groups..requireMention` | 是否需要 @提及 | `true` |
+| `channels.feishu.groups..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
### 接收
- ✅ 文本消息
+- ✅ 富文本(帖子)
- ✅ 图片
- ✅ 文件
- ✅ 音频
diff --git a/docs/zh-CN/channels/index.md b/docs/zh-CN/channels/index.md
index a41f0a28c59e..94835159ed45 100644
--- a/docs/zh-CN/channels/index.md
+++ b/docs/zh-CN/channels/index.md
@@ -20,26 +20,26 @@ OpenClaw 可以在你已经使用的任何聊天应用上与你交流。每个
## 支持的渠道
-- [WhatsApp](/channels/whatsapp) — 最受欢迎;使用 Baileys,需要二维码配对。
-- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API;支持群组。
+- [BlueBubbles](/channels/bluebubbles) — **推荐用于 iMessage**;使用 BlueBubbles macOS 服务器 REST API,功能完整(编辑、撤回、特效、回应、群组管理——编辑功能在 macOS 26 Tahoe 上目前不可用)。
- [Discord](/channels/discord) — Discord Bot API + Gateway;支持服务器、频道和私信。
-- [Slack](/channels/slack) — Bolt SDK;工作区应用。
- [飞书](/channels/feishu) — 飞书(Lark)机器人(插件,需单独安装)。
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
-- [Mattermost](/channels/mattermost) — Bot API + WebSocket;频道、群组、私信(插件,需单独安装)。
-- [Signal](/channels/signal) — signal-cli;注重隐私。
-- [BlueBubbles](/channels/bluebubbles) — **推荐用于 iMessage**;使用 BlueBubbles macOS 服务器 REST API,功能完整(编辑、撤回、特效、回应、群组管理——编辑功能在 macOS 26 Tahoe 上目前不可用)。
- [iMessage(旧版)](/channels/imessage) — 通过 imsg CLI 的旧版 macOS 集成(已弃用,新设置请使用 BlueBubbles)。
-- [Microsoft Teams](/channels/msteams) — Bot Framework;企业支持(插件,需单独安装)。
- [LINE](/channels/line) — LINE Messaging API 机器人(插件,需单独安装)。
-- [Nextcloud Talk](/channels/nextcloud-talk) — 通过 Nextcloud Talk 的自托管聊天(插件,需单独安装)。
- [Matrix](/channels/matrix) — Matrix 协议(插件,需单独安装)。
+- [Mattermost](/channels/mattermost) — Bot API + WebSocket;频道、群组、私信(插件,需单独安装)。
+- [Microsoft Teams](/channels/msteams) — Bot Framework;企业支持(插件,需单独安装)。
+- [Nextcloud Talk](/channels/nextcloud-talk) — 通过 Nextcloud Talk 的自托管聊天(插件,需单独安装)。
- [Nostr](/channels/nostr) — 通过 NIP-04 的去中心化私信(插件,需单独安装)。
+- [Signal](/channels/signal) — signal-cli;注重隐私。
+- [Slack](/channels/slack) — Bolt SDK;工作区应用。
+- [Telegram](/channels/telegram) — 通过 grammY 使用 Bot API;支持群组。
- [Tlon](/channels/tlon) — 基于 Urbit 的消息应用(插件,需单独安装)。
- [Twitch](/channels/twitch) — 通过 IRC 连接的 Twitch 聊天(插件,需单独安装)。
+- [WebChat](/web/webchat) — 基于 WebSocket 的 Gateway 网关 WebChat 界面。
+- [WhatsApp](/channels/whatsapp) — 最受欢迎;使用 Baileys,需要二维码配对。
- [Zalo](/channels/zalo) — Zalo Bot API;越南流行的消息应用(插件,需单独安装)。
- [Zalo Personal](/channels/zalouser) — 通过二维码登录的 Zalo 个人账号(插件,需单独安装)。
-- [WebChat](/web/webchat) — 基于 WebSocket 的 Gateway 网关 WebChat 界面。
## 注意事项
diff --git a/docs/zh-CN/concepts/sessions.md b/docs/zh-CN/concepts/sessions.md
deleted file mode 100644
index aa4f0f1c9896..000000000000
--- a/docs/zh-CN/concepts/sessions.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-read_when:
- - 你查找了 docs/sessions.md;规范文档位于 docs/session.md
-summary: 会话管理文档的别名
-title: 会话
-x-i18n:
- generated_at: "2026-02-01T20:23:55Z"
- model: claude-opus-4-5
- provider: pi
- source_hash: 7f1e39c3c07b9bb5cdcda361399cf1ce1226ebae3a797d8f93e734aa6a4d00e2
- source_path: concepts/sessions.md
- workflow: 14
----
-
-# 会话
-
-规范的会话管理文档位于[会话管理](/concepts/session)。
diff --git a/docs/zh-CN/providers/index.md b/docs/zh-CN/providers/index.md
index d3752f97f173..89ce5b277779 100644
--- a/docs/zh-CN/providers/index.md
+++ b/docs/zh-CN/providers/index.md
@@ -41,20 +41,19 @@ Venice 是我们推荐的 Venice AI 设置,用于隐私优先的推理,并
## 提供商文档
-- [OpenAI(API + Codex)](/providers/openai)
-- [Anthropic(API + Claude Code CLI)](/providers/anthropic)
-- [Qwen(OAuth)](/providers/qwen)
-- [OpenRouter](/providers/openrouter)
-- [Vercel AI Gateway](/providers/vercel-ai-gateway)
-- [Moonshot AI(Kimi + Kimi Coding)](/providers/moonshot)
-- [OpenCode Zen](/providers/opencode)
- [Amazon Bedrock](/providers/bedrock)
-- [Z.AI](/providers/zai)
-- [Xiaomi](/providers/xiaomi)
+- [Anthropic(API + Claude Code CLI)](/providers/anthropic)
- [GLM 模型](/providers/glm)
- [MiniMax](/providers/minimax)
-- [Venice(Venice AI,注重隐私)](/providers/venice)
+- [Moonshot AI(Kimi + Kimi Coding)](/providers/moonshot)
- [Ollama(本地模型)](/providers/ollama)
+- [OpenAI(API + Codex)](/providers/openai)
+- [OpenCode Zen](/providers/opencode)
+- [OpenRouter](/providers/openrouter)
+- [Qwen(OAuth)](/providers/qwen)
+- [Venice(Venice AI,注重隐私)](/providers/venice)
+- [Xiaomi](/providers/xiaomi)
+- [Z.AI](/providers/zai)
## 转录提供商
diff --git a/docs/zh-CN/start/hubs.md b/docs/zh-CN/start/hubs.md
index d4392700e064..a2e6260fdf2f 100644
--- a/docs/zh-CN/start/hubs.md
+++ b/docs/zh-CN/start/hubs.md
@@ -53,7 +53,6 @@ x-i18n:
- [多智能体路由](/concepts/multi-agent)
- [压缩](/concepts/compaction)
- [会话](/concepts/session)
-- [会话(别名)](/concepts/sessions)
- [会话修剪](/concepts/session-pruning)
- [会话工具](/concepts/session-tool)
- [队列](/concepts/queue)
diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json
index 39b0895af5ab..7a92fd1a4e66 100644
--- a/extensions/acpx/package.json
+++ b/extensions/acpx/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
- "version": "2026.3.1",
+ "version": "2026.3.2",
"description": "OpenClaw ACP runtime backend via acpx",
"type": "module",
"dependencies": {
diff --git a/extensions/acpx/skills/acp-router/SKILL.md b/extensions/acpx/skills/acp-router/SKILL.md
index a299c9e02294..1b7944820b1b 100644
--- a/extensions/acpx/skills/acp-router/SKILL.md
+++ b/extensions/acpx/skills/acp-router/SKILL.md
@@ -6,7 +6,7 @@ user-invocable: false
# ACP Harness Router
-When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
+When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini/Kimi (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
## Intent detection
@@ -39,7 +39,7 @@ Do not use:
- `subagents` runtime for harness control
- `/acp` command delegation as a requirement for the user
-- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
+- PTY scraping of pi/claude/codex/opencode/gemini/kimi CLIs when `acpx` is available
## AgentId mapping
@@ -50,6 +50,7 @@ Use these defaults when user names a harness directly:
- "codex" -> `agentId: "codex"`
- "opencode" -> `agentId: "opencode"`
- "gemini" or "gemini cli" -> `agentId: "gemini"`
+- "kimi" or "kimi cli" -> `agentId: "kimi"`
These defaults match current acpx built-in aliases.
@@ -87,7 +88,7 @@ Call:
## Thread spawn recovery policy
-When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
+When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi/kimi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
Required behavior when ACP backend is unavailable:
@@ -183,6 +184,7 @@ ${ACPX_CMD} codex sessions close oc-codex-
- `codex`
- `opencode`
- `gemini`
+- `kimi`
### Built-in adapter commands in acpx
@@ -193,6 +195,7 @@ Defaults are:
- `codex -> npx @zed-industries/codex-acp`
- `opencode -> npx -y opencode-ai acp`
- `gemini -> gemini`
+- `kimi -> kimi acp`
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
diff --git a/extensions/acpx/src/ensure.ts b/extensions/acpx/src/ensure.ts
index 94f0551d028e..dbe5807daa47 100644
--- a/extensions/acpx/src/ensure.ts
+++ b/extensions/acpx/src/ensure.ts
@@ -76,6 +76,28 @@ function resolveVersionFromPackage(command: string, cwd: string): string | null
}
}
+function resolveVersionCheckResult(params: {
+ expectedVersion?: string;
+ installedVersion: string;
+ installCommand: string;
+}): AcpxVersionCheckResult {
+ if (params.expectedVersion && params.installedVersion !== params.expectedVersion) {
+ return {
+ ok: false,
+ reason: "version-mismatch",
+ message: `acpx version mismatch: found ${params.installedVersion}, expected ${params.expectedVersion}`,
+ expectedVersion: params.expectedVersion,
+ installCommand: params.installCommand,
+ installedVersion: params.installedVersion,
+ };
+ }
+ return {
+ ok: true,
+ version: params.installedVersion,
+ expectedVersion: params.expectedVersion,
+ };
+}
+
export async function checkAcpxVersion(params: {
command: string;
cwd?: string;
@@ -131,21 +153,7 @@ export async function checkAcpxVersion(params: {
if (hasExpectedVersion && isUnsupportedVersionProbe(result.stdout, result.stderr)) {
const installedVersion = resolveVersionFromPackage(params.command, cwd);
if (installedVersion) {
- if (expectedVersion && installedVersion !== expectedVersion) {
- return {
- ok: false,
- reason: "version-mismatch",
- message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
- expectedVersion,
- installCommand,
- installedVersion,
- };
- }
- return {
- ok: true,
- version: installedVersion,
- expectedVersion,
- };
+ return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
}
}
const stderr = result.stderr.trim();
@@ -179,22 +187,7 @@ export async function checkAcpxVersion(params: {
};
}
- if (expectedVersion && installedVersion !== expectedVersion) {
- return {
- ok: false,
- reason: "version-mismatch",
- message: `acpx version mismatch: found ${installedVersion}, expected ${expectedVersion}`,
- expectedVersion,
- installCommand,
- installedVersion,
- };
- }
-
- return {
- ok: true,
- version: installedVersion,
- expectedVersion,
- };
+ return resolveVersionCheckResult({ expectedVersion, installedVersion, installCommand });
}
let pendingEnsure: Promise | null = null;
diff --git a/extensions/acpx/src/runtime-internals/test-fixtures.ts b/extensions/acpx/src/runtime-internals/test-fixtures.ts
index dcab6a829f54..928867418b81 100644
--- a/extensions/acpx/src/runtime-internals/test-fixtures.ts
+++ b/extensions/acpx/src/runtime-internals/test-fixtures.ts
@@ -14,6 +14,8 @@ export const NOOP_LOGGER = {
};
const tempDirs: string[] = [];
+let sharedMockCliScriptPath: Promise | null = null;
+let logFileSequence = 0;
const MOCK_CLI_SCRIPT = String.raw`#!/usr/bin/env node
const fs = require("node:fs");
@@ -263,14 +265,9 @@ export async function createMockRuntimeFixture(params?: {
logPath: string;
config: ResolvedAcpxPluginConfig;
}> {
- const dir = await mkdtemp(
- path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
- );
- tempDirs.push(dir);
- const scriptPath = path.join(dir, "mock-acpx.cjs");
- const logPath = path.join(dir, "calls.log");
- await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
- await chmod(scriptPath, 0o755);
+ const scriptPath = await ensureMockCliScriptPath();
+ const dir = path.dirname(scriptPath);
+ const logPath = path.join(dir, `calls-${logFileSequence++}.log`);
process.env.MOCK_ACPX_LOG = logPath;
const config: ResolvedAcpxPluginConfig = {
@@ -294,6 +291,23 @@ export async function createMockRuntimeFixture(params?: {
};
}
+async function ensureMockCliScriptPath(): Promise {
+ if (sharedMockCliScriptPath) {
+ return await sharedMockCliScriptPath;
+ }
+ sharedMockCliScriptPath = (async () => {
+ const dir = await mkdtemp(
+ path.join(resolvePreferredOpenClawTmpDir(), "openclaw-acpx-runtime-test-"),
+ );
+ tempDirs.push(dir);
+ const scriptPath = path.join(dir, "mock-acpx.cjs");
+ await writeFile(scriptPath, MOCK_CLI_SCRIPT, "utf8");
+ await chmod(scriptPath, 0o755);
+ return scriptPath;
+ })();
+ return await sharedMockCliScriptPath;
+}
+
export async function readMockRuntimeLogEntries(
logPath: string,
): Promise>> {
@@ -310,6 +324,8 @@ export async function readMockRuntimeLogEntries(
export async function cleanupMockRuntimeFixtures(): Promise {
delete process.env.MOCK_ACPX_LOG;
+ sharedMockCliScriptPath = null;
+ logFileSequence = 0;
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
diff --git a/extensions/acpx/src/runtime.test.ts b/extensions/acpx/src/runtime.test.ts
index 0c32065004ee..44f02cabd5a8 100644
--- a/extensions/acpx/src/runtime.test.ts
+++ b/extensions/acpx/src/runtime.test.ts
@@ -1,6 +1,6 @@
import os from "node:os";
import path from "node:path";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import {
cleanupMockRuntimeFixtures,
@@ -10,7 +10,29 @@ import {
} from "./runtime-internals/test-fixtures.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
-afterEach(async () => {
+let sharedFixture: Awaited> | null = null;
+let missingCommandRuntime: AcpxRuntime | null = null;
+
+beforeAll(async () => {
+ sharedFixture = await createMockRuntimeFixture();
+ missingCommandRuntime = new AcpxRuntime(
+ {
+ command: "/definitely/missing/acpx",
+ allowPluginLocalInstall: false,
+ installCommand: "n/a",
+ cwd: process.cwd(),
+ permissionMode: "approve-reads",
+ nonInteractivePermissions: "fail",
+ strictWindowsCmdWrapper: true,
+ queueOwnerTtlSeconds: 0.1,
+ },
+ { logger: NOOP_LOGGER },
+ );
+});
+
+afterAll(async () => {
+ sharedFixture = null;
+ missingCommandRuntime = null;
await cleanupMockRuntimeFixtures();
});
@@ -21,20 +43,14 @@ describe("AcpxRuntime", () => {
createRuntime: async () => fixture.runtime,
agentId: "codex",
successPrompt: "contract-pass",
- errorPrompt: "trigger-error",
+ includeControlChecks: false,
assertSuccessEvents: (events) => {
expect(events.some((event) => event.type === "done")).toBe(true);
},
- assertErrorOutcome: ({ events, thrown }) => {
- expect(events.some((event) => event.type === "error") || Boolean(thrown)).toBe(true);
- },
});
const logs = await readMockRuntimeLogEntries(fixture.logPath);
expect(logs.some((entry) => entry.kind === "ensure")).toBe(true);
- expect(logs.some((entry) => entry.kind === "status")).toBe(true);
- expect(logs.some((entry) => entry.kind === "set-mode")).toBe(true);
- expect(logs.some((entry) => entry.kind === "set")).toBe(true);
expect(logs.some((entry) => entry.kind === "cancel")).toBe(true);
expect(logs.some((entry) => entry.kind === "close")).toBe(true);
});
@@ -110,34 +126,12 @@ describe("AcpxRuntime", () => {
expect(promptArgs).toContain("--approve-all");
});
- it("passes a queue-owner TTL by default to avoid long idle stalls", async () => {
- const { runtime, logPath } = await createMockRuntimeFixture();
- const handle = await runtime.ensureSession({
- sessionKey: "agent:codex:acp:ttl-default",
- agent: "codex",
- mode: "persistent",
- });
-
- for await (const _event of runtime.runTurn({
- handle,
- text: "ttl-default",
- mode: "prompt",
- requestId: "req-ttl-default",
- })) {
- // drain
- }
-
- const logs = await readMockRuntimeLogEntries(logPath);
- const prompt = logs.find((entry) => entry.kind === "prompt");
- expect(prompt).toBeDefined();
- const promptArgs = (prompt?.args as string[]) ?? [];
- const ttlFlagIndex = promptArgs.indexOf("--ttl");
- expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
- expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
- });
-
it("preserves leading spaces across streamed text deltas", async () => {
- const { runtime } = await createMockRuntimeFixture();
+ const runtime = sharedFixture?.runtime;
+ expect(runtime).toBeDefined();
+ if (!runtime) {
+ throw new Error("shared runtime fixture missing");
+ }
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:space",
agent: "codex",
@@ -158,10 +152,28 @@ describe("AcpxRuntime", () => {
expect(textDeltas).toEqual(["alpha", " beta", " gamma"]);
expect(textDeltas.join("")).toBe("alpha beta gamma");
+
+ // Keep the default queue-owner TTL assertion on a runTurn that already exists.
+ const activeLogPath = process.env.MOCK_ACPX_LOG;
+ expect(activeLogPath).toBeDefined();
+ const logs = await readMockRuntimeLogEntries(String(activeLogPath));
+ const prompt = logs.find(
+ (entry) =>
+ entry.kind === "prompt" && String(entry.sessionName ?? "") === "agent:codex:acp:space",
+ );
+ expect(prompt).toBeDefined();
+ const promptArgs = (prompt?.args as string[]) ?? [];
+ const ttlFlagIndex = promptArgs.indexOf("--ttl");
+ expect(ttlFlagIndex).toBeGreaterThanOrEqual(0);
+ expect(promptArgs[ttlFlagIndex + 1]).toBe("0.1");
});
it("emits done once when ACP stream repeats stop reason responses", async () => {
- const { runtime } = await createMockRuntimeFixture();
+ const runtime = sharedFixture?.runtime;
+ expect(runtime).toBeDefined();
+ if (!runtime) {
+ throw new Error("shared runtime fixture missing");
+ }
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:double-done",
agent: "codex",
@@ -183,7 +195,11 @@ describe("AcpxRuntime", () => {
});
it("maps acpx error events into ACP runtime error events", async () => {
- const { runtime } = await createMockRuntimeFixture();
+ const runtime = sharedFixture?.runtime;
+ expect(runtime).toBeDefined();
+ if (!runtime) {
+ throw new Error("shared runtime fixture missing");
+ }
const handle = await runtime.ensureSession({
sessionKey: "agent:codex:acp:456",
agent: "codex",
@@ -318,28 +334,12 @@ describe("AcpxRuntime", () => {
});
it("marks runtime unhealthy when command is missing", async () => {
- const runtime = new AcpxRuntime(
- {
- command: "/definitely/missing/acpx",
- allowPluginLocalInstall: false,
- installCommand: "n/a",
- cwd: process.cwd(),
- permissionMode: "approve-reads",
- nonInteractivePermissions: "fail",
- strictWindowsCmdWrapper: true,
- queueOwnerTtlSeconds: 0.1,
- },
- { logger: NOOP_LOGGER },
- );
-
- await runtime.probeAvailability();
- expect(runtime.isHealthy()).toBe(false);
- });
-
- it("marks runtime healthy when command is available", async () => {
- const { runtime } = await createMockRuntimeFixture();
- await runtime.probeAvailability();
- expect(runtime.isHealthy()).toBe(true);
+ expect(missingCommandRuntime).toBeDefined();
+ if (!missingCommandRuntime) {
+ throw new Error("missing-command runtime fixture missing");
+ }
+ await missingCommandRuntime.probeAvailability();
+ expect(missingCommandRuntime.isHealthy()).toBe(false);
});
it("logs ACPX spawn resolution once per command policy", async () => {
@@ -368,21 +368,11 @@ describe("AcpxRuntime", () => {
});
it("returns doctor report for missing command", async () => {
- const runtime = new AcpxRuntime(
- {
- command: "/definitely/missing/acpx",
- allowPluginLocalInstall: false,
- installCommand: "n/a",
- cwd: process.cwd(),
- permissionMode: "approve-reads",
- nonInteractivePermissions: "fail",
- strictWindowsCmdWrapper: true,
- queueOwnerTtlSeconds: 0.1,
- },
- { logger: NOOP_LOGGER },
- );
-
- const report = await runtime.doctor();
+ expect(missingCommandRuntime).toBeDefined();
+ if (!missingCommandRuntime) {
+ throw new Error("missing-command runtime fixture missing");
+ }
+ const report = await missingCommandRuntime.doctor();
expect(report.ok).toBe(false);
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
expect(report.installCommand).toContain("acpx");
diff --git a/extensions/bluebubbles/README.md b/extensions/bluebubbles/README.md
index bd79f2502456..46fdd04e7f40 100644
--- a/extensions/bluebubbles/README.md
+++ b/extensions/bluebubbles/README.md
@@ -10,7 +10,7 @@ If you’re looking for **how to use BlueBubbles as an agent/tool user**, see:
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
-- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
+- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register per-account route via `registerPluginHttpRoute`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
diff --git a/extensions/bluebubbles/index.ts b/extensions/bluebubbles/index.ts
index 44b09e24592c..92bacb8d51a8 100644
--- a/extensions/bluebubbles/index.ts
+++ b/extensions/bluebubbles/index.ts
@@ -1,7 +1,6 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js";
-import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
import { setBlueBubblesRuntime } from "./src/runtime.js";
const plugin = {
@@ -12,7 +11,6 @@ const plugin = {
register(api: OpenClawPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
- api.registerHttpHandler(handleBlueBubblesWebhookRequest);
},
};
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index f3e9b6d7366f..d9bfaae8801d 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
- "version": "2026.3.1",
+ "version": "2026.3.2",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"openclaw": {
diff --git a/extensions/bluebubbles/src/account-resolve.ts b/extensions/bluebubbles/src/account-resolve.ts
index 904d21d4d3f2..ebdf7a7bc464 100644
--- a/extensions/bluebubbles/src/account-resolve.ts
+++ b/extensions/bluebubbles/src/account-resolve.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");
}
diff --git a/extensions/bluebubbles/src/accounts.test.ts b/extensions/bluebubbles/src/accounts.test.ts
new file mode 100644
index 000000000000..9fc801f8bf36
--- /dev/null
+++ b/extensions/bluebubbles/src/accounts.test.ts
@@ -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");
+ });
+});
diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts
index 6d09b5cbd16e..142e2d8fef9f 100644
--- a/extensions/bluebubbles/src/accounts.ts
+++ b/extensions/bluebubbles/src/accounts.ts
@@ -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,
diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts
index e774ef6c85ef..e85400748a93 100644
--- a/extensions/bluebubbles/src/actions.ts
+++ b/extensions/bluebubbles/src/actions.ts
@@ -5,6 +5,7 @@ import {
extractToolSend,
jsonResult,
readNumberParam,
+ readBooleanParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
@@ -24,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";
@@ -52,23 +54,6 @@ function readMessageText(params: Record): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
-function readBooleanParam(params: Record, key: string): boolean | undefined {
- const raw = params[key];
- if (typeof raw === "boolean") {
- return raw;
- }
- if (typeof raw === "string") {
- const trimmed = raw.trim().toLowerCase();
- if (trimmed === "true") {
- return true;
- }
- if (trimmed === "false") {
- return false;
- }
- }
- return undefined;
-}
-
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set(BLUEBUBBLES_ACTION_NAMES);
const PRIVATE_API_ACTIONS = new Set([
@@ -118,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) {
diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts
index 74ea0b759836..fbaa5ce39fcf 100644
--- a/extensions/bluebubbles/src/channel.ts
+++ b/extensions/bluebubbles/src/channel.ts
@@ -2,6 +2,7 @@ import type { ChannelAccountSnapshot, ChannelPlugin, OpenClawConfig } from "open
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
+ buildProbeChannelStatusSummary,
collectBlueBubblesStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
@@ -356,16 +357,8 @@ export const bluebubblesPlugin: ChannelPlugin = {
lastError: null,
},
collectStatusIssues: collectBlueBubblesStatusIssues,
- buildChannelSummary: ({ snapshot }) => ({
- configured: snapshot.configured ?? false,
- baseUrl: snapshot.baseUrl ?? null,
- running: snapshot.running ?? false,
- lastStartAt: snapshot.lastStartAt ?? null,
- lastStopAt: snapshot.lastStopAt ?? null,
- lastError: snapshot.lastError ?? null,
- probe: snapshot.probe,
- lastProbeAt: snapshot.lastProbeAt ?? null,
- }),
+ buildChannelSummary: ({ snapshot }) =>
+ buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
probeAccount: async ({ account, timeoutMs }) =>
probeBlueBubbles({
baseUrl: account.baseUrl,
diff --git a/extensions/bluebubbles/src/config-schema.test.ts b/extensions/bluebubbles/src/config-schema.test.ts
index be32c8f96b05..5bf66704d35f 100644
--- a/extensions/bluebubbles/src/config-schema.test.ts
+++ b/extensions/bluebubbles/src/config-schema.test.ts
@@ -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",
diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts
index 7f9b6ee46799..f4b6991441c6 100644
--- a/extensions/bluebubbles/src/config-schema.ts
+++ b/extensions/bluebubbles/src/config-schema.ts
@@ -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"],
diff --git a/extensions/bluebubbles/src/monitor-debounce.ts b/extensions/bluebubbles/src/monitor-debounce.ts
new file mode 100644
index 000000000000..952c591e8478
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor-debounce.ts
@@ -0,0 +1,205 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
+import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
+
+/**
+ * Entry type for debouncing inbound messages.
+ * Captures the normalized message and its target for later combined processing.
+ */
+type BlueBubblesDebounceEntry = {
+ message: NormalizedWebhookMessage;
+ target: WebhookTarget;
+};
+
+export type BlueBubblesDebouncer = {
+ enqueue: (item: BlueBubblesDebounceEntry) => Promise;
+ flushKey: (key: string) => Promise;
+};
+
+export type BlueBubblesDebounceRegistry = {
+ getOrCreateDebouncer: (target: WebhookTarget) => BlueBubblesDebouncer;
+ removeDebouncer: (target: WebhookTarget) => void;
+};
+
+/**
+ * Default debounce window for inbound message coalescing (ms).
+ * This helps combine URL text + link preview balloon messages that BlueBubbles
+ * sends as separate webhook events when no explicit inbound debounce config exists.
+ */
+const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
+
+/**
+ * Combines multiple debounced messages into a single message for processing.
+ * Used when multiple webhook events arrive within the debounce window.
+ */
+function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
+ if (entries.length === 0) {
+ throw new Error("Cannot combine empty entries");
+ }
+ if (entries.length === 1) {
+ return entries[0].message;
+ }
+
+ // Use the first message as the base (typically the text message)
+ const first = entries[0].message;
+
+ // Combine text from all entries, filtering out duplicates and empty strings
+ const seenTexts = new Set();
+ const textParts: string[] = [];
+
+ for (const entry of entries) {
+ const text = entry.message.text.trim();
+ if (!text) {
+ continue;
+ }
+ // Skip duplicate text (URL might be in both text message and balloon)
+ const normalizedText = text.toLowerCase();
+ if (seenTexts.has(normalizedText)) {
+ continue;
+ }
+ seenTexts.add(normalizedText);
+ textParts.push(text);
+ }
+
+ // Merge attachments from all entries
+ const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
+
+ // Use the latest timestamp
+ const timestamps = entries
+ .map((e) => e.message.timestamp)
+ .filter((t): t is number => typeof t === "number");
+ const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
+
+ // Collect all message IDs for reference
+ const messageIds = entries
+ .map((e) => e.message.messageId)
+ .filter((id): id is string => Boolean(id));
+
+ // Prefer reply context from any entry that has it
+ const entryWithReply = entries.find((e) => e.message.replyToId);
+
+ return {
+ ...first,
+ text: textParts.join(" "),
+ attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
+ timestamp: latestTimestamp,
+ // Use first message's ID as primary (for reply reference), but we've coalesced others
+ messageId: messageIds[0] ?? first.messageId,
+ // Preserve reply context if present
+ replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
+ replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
+ replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
+ // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
+ balloonBundleId: undefined,
+ };
+}
+
+function resolveBlueBubblesDebounceMs(
+ config: OpenClawConfig,
+ core: BlueBubblesCoreRuntime,
+): number {
+ const inbound = config.messages?.inbound;
+ const hasExplicitDebounce =
+ typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
+ if (!hasExplicitDebounce) {
+ return DEFAULT_INBOUND_DEBOUNCE_MS;
+ }
+ return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
+}
+
+export function createBlueBubblesDebounceRegistry(params: {
+ processMessage: (message: NormalizedWebhookMessage, target: WebhookTarget) => Promise;
+}): BlueBubblesDebounceRegistry {
+ const targetDebouncers = new Map();
+
+ return {
+ getOrCreateDebouncer: (target) => {
+ const existing = targetDebouncers.get(target);
+ if (existing) {
+ return existing;
+ }
+
+ const { account, config, runtime, core } = target;
+ const debouncer = core.channel.debounce.createInboundDebouncer({
+ debounceMs: resolveBlueBubblesDebounceMs(config, core),
+ buildKey: (entry) => {
+ const msg = entry.message;
+ // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
+ // same message (e.g., text-only then text+attachment).
+ //
+ // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
+ // messageId than the originating text. When present, key by associatedMessageGuid
+ // to keep text + balloon coalescing working.
+ const balloonBundleId = msg.balloonBundleId?.trim();
+ const associatedMessageGuid = msg.associatedMessageGuid?.trim();
+ if (balloonBundleId && associatedMessageGuid) {
+ return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
+ }
+
+ const messageId = msg.messageId?.trim();
+ if (messageId) {
+ return `bluebubbles:${account.accountId}:msg:${messageId}`;
+ }
+
+ const chatKey =
+ msg.chatGuid?.trim() ??
+ msg.chatIdentifier?.trim() ??
+ (msg.chatId ? String(msg.chatId) : "dm");
+ return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
+ },
+ shouldDebounce: (entry) => {
+ const msg = entry.message;
+ // Skip debouncing for from-me messages (they're just cached, not processed)
+ if (msg.fromMe) {
+ return false;
+ }
+ // Skip debouncing for control commands - process immediately
+ if (core.channel.text.hasControlCommand(msg.text, config)) {
+ return false;
+ }
+ // Debounce all other messages to coalesce rapid-fire webhook events
+ // (e.g., text+image arriving as separate webhooks for the same messageId)
+ return true;
+ },
+ onFlush: async (entries) => {
+ if (entries.length === 0) {
+ return;
+ }
+
+ // Use target from first entry (all entries have same target due to key structure)
+ const flushTarget = entries[0].target;
+
+ if (entries.length === 1) {
+ // Single message - process normally
+ await params.processMessage(entries[0].message, flushTarget);
+ return;
+ }
+
+ // Multiple messages - combine and process
+ const combined = combineDebounceEntries(entries);
+
+ if (core.logging.shouldLogVerbose()) {
+ const count = entries.length;
+ const preview = combined.text.slice(0, 50);
+ runtime.log?.(
+ `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
+ );
+ }
+
+ await params.processMessage(combined, flushTarget);
+ },
+ onError: (err) => {
+ runtime.error?.(
+ `[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`,
+ );
+ },
+ });
+
+ targetDebouncers.set(target, debouncer);
+ return debouncer;
+ },
+ removeDebouncer: (target) => {
+ targetDebouncers.delete(target);
+ },
+ };
+}
diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts
index 2ea420349073..de26a7d0c545 100644
--- a/extensions/bluebubbles/src/monitor-processing.ts
+++ b/extensions/bluebubbles/src/monitor-processing.ts
@@ -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
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index 43777f648ade..c914050616de 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -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,127 +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 => 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"],
- 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"],
- },
- 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[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) => 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"],
},
@@ -195,99 +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 }) => ({
- 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"],
},
- 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(
@@ -394,573 +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";
- 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("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[] = [
- { 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({
@@ -2467,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);
@@ -2517,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);
@@ -2562,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();
@@ -2584,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();
@@ -2635,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();
@@ -2707,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();
diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts
index fa148e5dd20a..a0e06bce6d80 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -1,20 +1,15 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
-import type { OpenClawConfig } from "openclaw/plugin-sdk";
import {
- isRequestBodyLimitError,
- readRequestBodyWithLimit,
- registerWebhookTarget,
- rejectNonPostWebhookRequest,
- requestBodyErrorToText,
- resolveSingleWebhookTarget,
+ beginWebhookRequestPipelineOrReject,
+ createWebhookInFlightLimiter,
+ registerWebhookTargetWithPluginRoute,
+ readWebhookBodyOrReject,
+ resolveWebhookTargetWithAuthOrRejectSync,
resolveWebhookTargets,
} from "openclaw/plugin-sdk";
-import {
- normalizeWebhookMessage,
- normalizeWebhookReaction,
- type NormalizedWebhookMessage,
-} from "./monitor-normalize.js";
+import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
+import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
import {
_resetBlueBubblesShortIdState,
@@ -24,229 +19,44 @@ import {
DEFAULT_WEBHOOK_PATH,
normalizeWebhookPath,
resolveWebhookPathFromConfig,
- type BlueBubblesCoreRuntime,
type BlueBubblesMonitorOptions,
type WebhookTarget,
} from "./monitor-shared.js";
import { fetchBlueBubblesServerInfo } from "./probe.js";
import { getBlueBubblesRuntime } from "./runtime.js";
-/**
- * Entry type for debouncing inbound messages.
- * Captures the normalized message and its target for later combined processing.
- */
-type BlueBubblesDebounceEntry = {
- message: NormalizedWebhookMessage;
- target: WebhookTarget;
-};
-
-/**
- * Default debounce window for inbound message coalescing (ms).
- * This helps combine URL text + link preview balloon messages that BlueBubbles
- * sends as separate webhook events when no explicit inbound debounce config exists.
- */
-const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
-
-/**
- * Combines multiple debounced messages into a single message for processing.
- * Used when multiple webhook events arrive within the debounce window.
- */
-function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
- if (entries.length === 0) {
- throw new Error("Cannot combine empty entries");
- }
- if (entries.length === 1) {
- return entries[0].message;
- }
-
- // Use the first message as the base (typically the text message)
- const first = entries[0].message;
-
- // Combine text from all entries, filtering out duplicates and empty strings
- const seenTexts = new Set();
- const textParts: string[] = [];
-
- for (const entry of entries) {
- const text = entry.message.text.trim();
- if (!text) {
- continue;
- }
- // Skip duplicate text (URL might be in both text message and balloon)
- const normalizedText = text.toLowerCase();
- if (seenTexts.has(normalizedText)) {
- continue;
- }
- seenTexts.add(normalizedText);
- textParts.push(text);
- }
-
- // Merge attachments from all entries
- const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
-
- // Use the latest timestamp
- const timestamps = entries
- .map((e) => e.message.timestamp)
- .filter((t): t is number => typeof t === "number");
- const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
-
- // Collect all message IDs for reference
- const messageIds = entries
- .map((e) => e.message.messageId)
- .filter((id): id is string => Boolean(id));
-
- // Prefer reply context from any entry that has it
- const entryWithReply = entries.find((e) => e.message.replyToId);
-
- return {
- ...first,
- text: textParts.join(" "),
- attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
- timestamp: latestTimestamp,
- // Use first message's ID as primary (for reply reference), but we've coalesced others
- messageId: messageIds[0] ?? first.messageId,
- // Preserve reply context if present
- replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
- replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
- replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
- // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
- balloonBundleId: undefined,
- };
-}
-
const webhookTargets = new Map();
+const webhookInFlightLimiter = createWebhookInFlightLimiter();
+const debounceRegistry = createBlueBubblesDebounceRegistry({ processMessage });
-type BlueBubblesDebouncer = {
- enqueue: (item: BlueBubblesDebounceEntry) => Promise;
- flushKey: (key: string) => Promise;
-};
-
-/**
- * Maps webhook targets to their inbound debouncers.
- * Each target gets its own debouncer keyed by a unique identifier.
- */
-const targetDebouncers = new Map();
-
-function resolveBlueBubblesDebounceMs(
- config: OpenClawConfig,
- core: BlueBubblesCoreRuntime,
-): number {
- const inbound = config.messages?.inbound;
- const hasExplicitDebounce =
- typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
- if (!hasExplicitDebounce) {
- return DEFAULT_INBOUND_DEBOUNCE_MS;
- }
- return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
-}
-
-/**
- * Creates or retrieves a debouncer for a webhook target.
- */
-function getOrCreateDebouncer(target: WebhookTarget) {
- const existing = targetDebouncers.get(target);
- if (existing) {
- return existing;
- }
-
- const { account, config, runtime, core } = target;
-
- const debouncer = core.channel.debounce.createInboundDebouncer({
- debounceMs: resolveBlueBubblesDebounceMs(config, core),
- buildKey: (entry) => {
- const msg = entry.message;
- // Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
- // same message (e.g., text-only then text+attachment).
- //
- // For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
- // messageId than the originating text. When present, key by associatedMessageGuid
- // to keep text + balloon coalescing working.
- const balloonBundleId = msg.balloonBundleId?.trim();
- const associatedMessageGuid = msg.associatedMessageGuid?.trim();
- if (balloonBundleId && associatedMessageGuid) {
- return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
- }
-
- const messageId = msg.messageId?.trim();
- if (messageId) {
- return `bluebubbles:${account.accountId}:msg:${messageId}`;
- }
-
- const chatKey =
- msg.chatGuid?.trim() ??
- msg.chatIdentifier?.trim() ??
- (msg.chatId ? String(msg.chatId) : "dm");
- return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
- },
- shouldDebounce: (entry) => {
- const msg = entry.message;
- // Skip debouncing for from-me messages (they're just cached, not processed)
- if (msg.fromMe) {
- return false;
- }
- // Skip debouncing for control commands - process immediately
- if (core.channel.text.hasControlCommand(msg.text, config)) {
- return false;
- }
- // Debounce all other messages to coalesce rapid-fire webhook events
- // (e.g., text+image arriving as separate webhooks for the same messageId)
- return true;
- },
- onFlush: async (entries) => {
- if (entries.length === 0) {
- return;
- }
-
- // Use target from first entry (all entries have same target due to key structure)
- const flushTarget = entries[0].target;
-
- if (entries.length === 1) {
- // Single message - process normally
- await processMessage(entries[0].message, flushTarget);
- return;
- }
-
- // Multiple messages - combine and process
- const combined = combineDebounceEntries(entries);
-
- if (core.logging.shouldLogVerbose()) {
- const count = entries.length;
- const preview = combined.text.slice(0, 50);
- runtime.log?.(
- `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
- );
- }
-
- await processMessage(combined, flushTarget);
- },
- onError: (err) => {
- runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
+export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
+ const registered = registerWebhookTargetWithPluginRoute({
+ targetsByPath: webhookTargets,
+ target,
+ route: {
+ auth: "plugin",
+ match: "exact",
+ pluginId: "bluebubbles",
+ source: "bluebubbles-webhook",
+ accountId: target.account.accountId,
+ log: target.runtime.log,
+ handler: async (req, res) => {
+ const handled = await handleBlueBubblesWebhookRequest(req, res);
+ if (!handled && !res.headersSent) {
+ res.statusCode = 404;
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
+ res.end("Not Found");
+ }
+ },
},
});
-
- targetDebouncers.set(target, debouncer);
- return debouncer;
-}
-
-/**
- * Removes a debouncer for a target (called during unregistration).
- */
-function removeDebouncer(target: WebhookTarget): void {
- targetDebouncers.delete(target);
-}
-
-export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
- const registered = registerWebhookTarget(webhookTargets, target);
return () => {
registered.unregister();
// Clean up debouncer when target is unregistered
- removeDebouncer(registered.target);
+ debounceRegistry.removeDebouncer(registered.target);
};
}
-type ReadBlueBubblesWebhookBodyResult =
- | { ok: true; value: unknown }
- | { ok: false; statusCode: number; error: string };
-
function parseBlueBubblesWebhookPayload(
rawBody: string,
): { ok: true; value: unknown } | { ok: false; error: string } {
@@ -270,36 +80,6 @@ function parseBlueBubblesWebhookPayload(
}
}
-async function readBlueBubblesWebhookBody(
- req: IncomingMessage,
- maxBytes: number,
-): Promise {
- try {
- const rawBody = await readRequestBodyWithLimit(req, {
- maxBytes,
- timeoutMs: 30_000,
- });
- const parsed = parseBlueBubblesWebhookPayload(rawBody);
- if (!parsed.ok) {
- return { ok: false, statusCode: 400, error: parsed.error };
- }
- return parsed;
- } catch (error) {
- if (isRequestBodyLimitError(error)) {
- return {
- ok: false,
- statusCode: error.statusCode,
- error: requestBodyErrorToText(error.code),
- };
- }
- return {
- ok: false,
- statusCode: 400,
- error: error instanceof Error ? error.message : String(error),
- };
- }
-}
-
function asRecord(value: unknown): Record | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record)
@@ -348,137 +128,150 @@ export async function handleBlueBubblesWebhookRequest(
}
const { path, targets } = resolved;
const url = new URL(req.url ?? "/", "http://localhost");
-
- if (rejectNonPostWebhookRequest(req, res)) {
- return true;
- }
-
- const body = await readBlueBubblesWebhookBody(req, 1024 * 1024);
- if (!body.ok) {
- res.statusCode = body.statusCode;
- res.end(body.error ?? "invalid payload");
- console.warn(`[bluebubbles] webhook rejected: ${body.error ?? "invalid payload"}`);
- return true;
- }
-
- const payload = asRecord(body.value) ?? {};
- const firstTarget = targets[0];
- if (firstTarget) {
- logVerbose(
- firstTarget.core,
- firstTarget.runtime,
- `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
- );
- }
- const eventTypeRaw = payload.type;
- const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
- const allowedEventTypes = new Set([
- "new-message",
- "updated-message",
- "message-reaction",
- "reaction",
- ]);
- if (eventType && !allowedEventTypes.has(eventType)) {
- res.statusCode = 200;
- res.end("ok");
- if (firstTarget) {
- logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
- }
- return true;
- }
- const reaction = normalizeWebhookReaction(payload);
- if (
- (eventType === "updated-message" ||
- eventType === "message-reaction" ||
- eventType === "reaction") &&
- !reaction
- ) {
- res.statusCode = 200;
- res.end("ok");
- if (firstTarget) {
- logVerbose(
- firstTarget.core,
- firstTarget.runtime,
- `webhook ignored ${eventType || "event"} without reaction`,
- );
- }
- return true;
- }
- const message = reaction ? null : normalizeWebhookMessage(payload);
- if (!message && !reaction) {
- res.statusCode = 400;
- res.end("invalid payload");
- console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
- return true;
- }
-
- const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
- const headerToken =
- req.headers["x-guid"] ??
- req.headers["x-password"] ??
- req.headers["x-bluebubbles-guid"] ??
- req.headers["authorization"];
- const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
- const matchedTarget = resolveSingleWebhookTarget(targets, (target) => {
- const token = target.account.config.password?.trim() ?? "";
- return safeEqualSecret(guid, token);
+ const requestLifecycle = beginWebhookRequestPipelineOrReject({
+ req,
+ res,
+ allowMethods: ["POST"],
+ inFlightLimiter: webhookInFlightLimiter,
+ inFlightKey: `${path}:${req.socket.remoteAddress ?? "unknown"}`,
});
-
- if (matchedTarget.kind === "none") {
- res.statusCode = 401;
- res.end("unauthorized");
- console.warn(
- `[bluebubbles] webhook rejected: unauthorized guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
- );
- return true;
- }
-
- if (matchedTarget.kind === "ambiguous") {
- res.statusCode = 401;
- res.end("ambiguous webhook target");
- console.warn(`[bluebubbles] webhook rejected: ambiguous target match path=${path}`);
+ if (!requestLifecycle.ok) {
return true;
}
- const target = matchedTarget.target;
- target.statusSink?.({ lastInboundAt: Date.now() });
- if (reaction) {
- processReaction(reaction, target).catch((err) => {
- target.runtime.error?.(
- `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
- );
+ try {
+ const guidParam = url.searchParams.get("guid") ?? url.searchParams.get("password");
+ const headerToken =
+ req.headers["x-guid"] ??
+ req.headers["x-password"] ??
+ req.headers["x-bluebubbles-guid"] ??
+ req.headers["authorization"];
+ const guid = (Array.isArray(headerToken) ? headerToken[0] : headerToken) ?? guidParam ?? "";
+ const target = resolveWebhookTargetWithAuthOrRejectSync({
+ targets,
+ res,
+ isMatch: (target) => {
+ const token = target.account.config.password?.trim() ?? "";
+ return safeEqualSecret(guid, token);
+ },
});
- } else if (message) {
- // Route messages through debouncer to coalesce rapid-fire events
- // (e.g., text message + URL balloon arriving as separate webhooks)
- const debouncer = getOrCreateDebouncer(target);
- debouncer.enqueue({ message, target }).catch((err) => {
- target.runtime.error?.(
- `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
+ if (!target) {
+ console.warn(
+ `[bluebubbles] webhook rejected: status=${res.statusCode} path=${path} guid=${maskSecret(url.searchParams.get("guid") ?? url.searchParams.get("password") ?? "")}`,
);
+ return true;
+ }
+ const body = await readWebhookBodyOrReject({
+ req,
+ res,
+ profile: "post-auth",
+ invalidBodyMessage: "invalid payload",
});
- }
+ if (!body.ok) {
+ console.warn(`[bluebubbles] webhook rejected: status=${res.statusCode}`);
+ return true;
+ }
- res.statusCode = 200;
- res.end("ok");
- if (reaction) {
- if (firstTarget) {
- logVerbose(
- firstTarget.core,
- firstTarget.runtime,
- `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
- );
+ const parsed = parseBlueBubblesWebhookPayload(body.value);
+ if (!parsed.ok) {
+ res.statusCode = 400;
+ res.end(parsed.error);
+ console.warn(`[bluebubbles] webhook rejected: ${parsed.error}`);
+ return true;
}
- } else if (message) {
+
+ const payload = asRecord(parsed.value) ?? {};
+ const firstTarget = targets[0];
if (firstTarget) {
logVerbose(
firstTarget.core,
firstTarget.runtime,
- `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
+ `webhook received path=${path} keys=${Object.keys(payload).join(",") || "none"}`,
);
}
+ const eventTypeRaw = payload.type;
+ const eventType = typeof eventTypeRaw === "string" ? eventTypeRaw.trim() : "";
+ const allowedEventTypes = new Set([
+ "new-message",
+ "updated-message",
+ "message-reaction",
+ "reaction",
+ ]);
+ if (eventType && !allowedEventTypes.has(eventType)) {
+ res.statusCode = 200;
+ res.end("ok");
+ if (firstTarget) {
+ logVerbose(firstTarget.core, firstTarget.runtime, `webhook ignored type=${eventType}`);
+ }
+ return true;
+ }
+ const reaction = normalizeWebhookReaction(payload);
+ if (
+ (eventType === "updated-message" ||
+ eventType === "message-reaction" ||
+ eventType === "reaction") &&
+ !reaction
+ ) {
+ res.statusCode = 200;
+ res.end("ok");
+ if (firstTarget) {
+ logVerbose(
+ firstTarget.core,
+ firstTarget.runtime,
+ `webhook ignored ${eventType || "event"} without reaction`,
+ );
+ }
+ return true;
+ }
+ const message = reaction ? null : normalizeWebhookMessage(payload);
+ if (!message && !reaction) {
+ res.statusCode = 400;
+ res.end("invalid payload");
+ console.warn("[bluebubbles] webhook rejected: unable to parse message payload");
+ return true;
+ }
+
+ target.statusSink?.({ lastInboundAt: Date.now() });
+ if (reaction) {
+ processReaction(reaction, target).catch((err) => {
+ target.runtime.error?.(
+ `[${target.account.accountId}] BlueBubbles reaction failed: ${String(err)}`,
+ );
+ });
+ } else if (message) {
+ // Route messages through debouncer to coalesce rapid-fire events
+ // (e.g., text message + URL balloon arriving as separate webhooks)
+ const debouncer = debounceRegistry.getOrCreateDebouncer(target);
+ debouncer.enqueue({ message, target }).catch((err) => {
+ target.runtime.error?.(
+ `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
+ );
+ });
+ }
+
+ res.statusCode = 200;
+ res.end("ok");
+ if (reaction) {
+ if (firstTarget) {
+ logVerbose(
+ firstTarget.core,
+ firstTarget.runtime,
+ `webhook accepted reaction sender=${reaction.senderId} msg=${reaction.messageId} action=${reaction.action}`,
+ );
+ }
+ } else if (message) {
+ if (firstTarget) {
+ logVerbose(
+ firstTarget.core,
+ firstTarget.runtime,
+ `webhook accepted sender=${message.senderId} group=${message.isGroup} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
+ );
+ }
+ }
+ return true;
+ } finally {
+ requestLifecycle.release();
}
- return true;
}
export async function monitorBlueBubblesProvider(
diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
new file mode 100644
index 000000000000..72e765fcd57c
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
@@ -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("./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 {
+ 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 = {},
+): 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((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[] = [
+ { 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),
+ );
+ });
+ });
+});
diff --git a/extensions/bluebubbles/src/monitor.webhook-route.test.ts b/extensions/bluebubbles/src/monitor.webhook-route.test.ts
new file mode 100644
index 000000000000..8499ea56b3d9
--- /dev/null
+++ b/extensions/bluebubbles/src/monitor.webhook-route.test.ts
@@ -0,0 +1,44 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk";
+import { afterEach, describe, expect, it } from "vitest";
+import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
+import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
+import type { WebhookTarget } from "./monitor-shared.js";
+import { registerBlueBubblesWebhookTarget } from "./monitor.js";
+
+function createTarget(): WebhookTarget {
+ return {
+ account: { accountId: "default" } as WebhookTarget["account"],
+ config: {} as OpenClawConfig,
+ runtime: {},
+ core: {} as WebhookTarget["core"],
+ path: "/bluebubbles-webhook",
+ };
+}
+
+describe("registerBlueBubblesWebhookTarget", () => {
+ afterEach(() => {
+ setActivePluginRegistry(createEmptyPluginRegistry());
+ });
+
+ it("registers and unregisters plugin HTTP route at path boundaries", () => {
+ const registry = createEmptyPluginRegistry();
+ setActivePluginRegistry(registry);
+
+ const unregisterA = registerBlueBubblesWebhookTarget(createTarget());
+ const unregisterB = registerBlueBubblesWebhookTarget(createTarget());
+
+ expect(registry.httpRoutes).toHaveLength(1);
+ expect(registry.httpRoutes[0]).toEqual(
+ expect.objectContaining({
+ pluginId: "bluebubbles",
+ path: "/bluebubbles-webhook",
+ source: "bluebubbles-webhook",
+ }),
+ );
+
+ unregisterA();
+ expect(registry.httpRoutes).toHaveLength(1);
+ unregisterB();
+ expect(registry.httpRoutes).toHaveLength(0);
+ });
+});
diff --git a/extensions/bluebubbles/src/onboarding.secret-input.test.ts b/extensions/bluebubbles/src/onboarding.secret-input.test.ts
new file mode 100644
index 000000000000..7452ae3c2d4f
--- /dev/null
+++ b/extensions/bluebubbles/src/onboarding.secret-input.test.ts
@@ -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
+ >[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();
+ });
+});
diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts
index 78b2876b5e0e..5eb0d6e40661 100644
--- a/extensions/bluebubbles/src/onboarding.ts
+++ b/extensions/bluebubbles/src/onboarding.ts
@@ -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;
}
}
diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts
index 5ee95a26821d..eeeba033ee21 100644
--- a/extensions/bluebubbles/src/probe.ts
+++ b/extensions/bluebubbles/src/probe.ts
@@ -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 {
- 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 {
- 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" };
}
diff --git a/extensions/bluebubbles/src/secret-input.ts b/extensions/bluebubbles/src/secret-input.ts
new file mode 100644
index 000000000000..f90d41c6fb9b
--- /dev/null
+++ b/extensions/bluebubbles/src/secret-input.ts
@@ -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),
+ }),
+ ]);
+}
diff --git a/extensions/bluebubbles/src/send-helpers.ts b/extensions/bluebubbles/src/send-helpers.ts
index 53e03a92c8c4..6fa2ab743cdc 100644
--- a/extensions/bluebubbles/src/send-helpers.ts
+++ b/extensions/bluebubbles/src/send-helpers.ts
@@ -23,31 +23,43 @@ export function extractBlueBubblesMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return "unknown";
}
- const record = payload as Record;
- const data =
- record.data && typeof record.data === "object"
- ? (record.data as Record