mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 09:21:55 +00:00
Merge branch 'main' into vincentkoc-code/slack-block-kit-interactions
This commit is contained in:
4
.github/actions/setup-node-env/action.yml
vendored
4
.github/actions/setup-node-env/action.yml
vendored
@@ -49,7 +49,7 @@ runs:
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: false
|
||||
@@ -63,7 +63,7 @@ runs:
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@v2.1.3
|
||||
with:
|
||||
bun-version: "1.3.9"
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ runs:
|
||||
- name: Restore pnpm store cache (exact key only)
|
||||
# PRs that request sticky disks still need a safe cache restore path.
|
||||
if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
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' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
11
.github/workflows/auto-response.yml
vendored
11
.github/workflows/auto-response.yml
vendored
@@ -5,9 +5,12 @@ on:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
|
||||
types: [labeled]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
@@ -17,20 +20,20 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
||||
89
.github/workflows/ci.yml
vendored
89
.github/workflows/ci.yml
vendored
@@ -9,6 +9,9 @@ concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
@@ -19,7 +22,7 @@ jobs:
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
@@ -50,7 +53,7 @@ jobs:
|
||||
run_windows: ${{ steps.scope.outputs.run_windows }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
@@ -83,7 +86,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -98,13 +101,13 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
@@ -117,7 +120,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -125,10 +128,10 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
@@ -163,7 +166,7 @@ jobs:
|
||||
|
||||
- name: Checkout
|
||||
if: github.event_name != 'push' || matrix.runtime != 'bun'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -172,7 +175,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "${{ matrix.runtime == 'bun' }}"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: (github.event_name != 'push' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
@@ -194,7 +197,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -202,7 +205,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Check types and lint and oxfmt
|
||||
run: pnpm check
|
||||
@@ -220,7 +223,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -228,7 +231,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
@@ -240,7 +243,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -250,7 +253,7 @@ jobs:
|
||||
node-version: "22.x"
|
||||
cache-key-suffix: "node22"
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Configure Node 22 test resources
|
||||
run: |
|
||||
@@ -273,12 +276,12 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -297,7 +300,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -316,7 +319,7 @@ jobs:
|
||||
|
||||
- name: Setup Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
@@ -326,7 +329,7 @@ jobs:
|
||||
.github/workflows/ci.yml
|
||||
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
key: pre-commit-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
@@ -409,7 +412,7 @@ jobs:
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -433,7 +436,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24.x
|
||||
check-latest: false
|
||||
@@ -495,7 +498,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -531,7 +534,7 @@ jobs:
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Cache SwiftPM
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/Library/Caches/org.swift.swiftpm
|
||||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||||
@@ -567,7 +570,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -736,31 +739,45 @@ jobs:
|
||||
command: ./gradlew --no-daemon :app:assembleDebug
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
|
||||
# Keep sdkmanager on the stable JDK path for Linux CI runners.
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
accept-android-sdk-licenses: false
|
||||
- name: Setup Android SDK cmdline-tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
||||
CMDLINE_TOOLS_VERSION="12266719"
|
||||
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
||||
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
||||
|
||||
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
curl -fsSL "$URL" -o "/tmp/${ARCHIVE}"
|
||||
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools"
|
||||
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
|
||||
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
|
||||
echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
|
||||
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v5
|
||||
with:
|
||||
gradle-version: 8.11.1
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
yes | sdkmanager --licenses >/dev/null
|
||||
sdkmanager --install \
|
||||
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
|
||||
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-36" \
|
||||
"build-tools;36.0.0"
|
||||
|
||||
11
.github/workflows/codeql.yml
vendored
11
.github/workflows/codeql.yml
vendored
@@ -7,6 +7,9 @@ concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -67,7 +70,7 @@ jobs:
|
||||
config_file: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
@@ -76,17 +79,17 @@ jobs:
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "true"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Setup Python
|
||||
if: matrix.needs_python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Setup Java
|
||||
if: matrix.needs_java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
17
.github/workflows/docker-release.yml
vendored
17
.github/workflows/docker-release.yml
vendored
@@ -18,6 +18,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
@@ -33,13 +34,13 @@ jobs:
|
||||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -134,13 +135,13 @@ jobs:
|
||||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -233,10 +234,10 @@ jobs:
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
9
.github/workflows/install-smoke.yml
vendored
9
.github/workflows/install-smoke.yml
vendored
@@ -10,6 +10,9 @@ concurrency:
|
||||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
@@ -17,7 +20,7 @@ jobs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
@@ -38,10 +41,10 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
# Blacksmith can fall back to the local docker driver, which rejects gha
|
||||
# cache export/import. Keep smoke builds driver-agnostic.
|
||||
|
||||
29
.github/workflows/labeler.yml
vendored
29
.github/workflows/labeler.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Labeler
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned triage workflow; no untrusted checkout or PR code execution
|
||||
types: [opened, synchronize, reopened]
|
||||
issues:
|
||||
types: [opened]
|
||||
@@ -16,6 +16,9 @@ on:
|
||||
required: false
|
||||
default: "50"
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
@@ -25,25 +28,25 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
sync-labels: true
|
||||
- name: Apply PR size label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
@@ -132,7 +135,7 @@ jobs:
|
||||
labels: [targetSizeLabel],
|
||||
});
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
@@ -203,7 +206,7 @@ jobs:
|
||||
// });
|
||||
// }
|
||||
- name: Apply too-many-prs label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
@@ -381,20 +384,20 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Backfill PR labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
@@ -629,20 +632,20 @@ jobs:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Apply maintainer or trusted-contributor label
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
|
||||
3
.github/workflows/openclaw-npm-release.yml
vendored
3
.github/workflows/openclaw-npm-release.yml
vendored
@@ -10,6 +10,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
@@ -22,7 +23,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
7
.github/workflows/sandbox-common-smoke.yml
vendored
7
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -17,17 +17,20 @@ concurrency:
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
|
||||
17
.github/workflows/stale.yml
vendored
17
.github/workflows/stale.yml
vendored
@@ -5,6 +5,9 @@ on:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
@@ -14,13 +17,13 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
with:
|
||||
@@ -29,7 +32,7 @@ jobs:
|
||||
- name: Mark stale issues and pull requests (primary)
|
||||
id: stale-primary
|
||||
continue-on-error: true
|
||||
uses: actions/stale@v9
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
@@ -62,7 +65,7 @@ jobs:
|
||||
- name: Check stale state cache
|
||||
id: stale-state
|
||||
if: always()
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token-fallback.outputs.token || steps.app-token.outputs.token }}
|
||||
script: |
|
||||
@@ -85,7 +88,7 @@ jobs:
|
||||
}
|
||||
- name: Mark stale issues and pull requests (fallback)
|
||||
if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
|
||||
uses: actions/stale@v9
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 7
|
||||
@@ -121,13 +124,13 @@ jobs:
|
||||
issues: write
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
- uses: actions/create-github-app-token@v2
|
||||
id: app-token
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: Lock closed issues after 48h of no comments
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
|
||||
7
.github/workflows/workflow-sanity.yml
vendored
7
.github/workflows/workflow-sanity.yml
vendored
@@ -9,12 +9,15 @@ concurrency:
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fail on tabs in workflow files
|
||||
run: |
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.override.yml
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
@@ -128,6 +129,7 @@ docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
|
||||
.gitignore
|
||||
test/config-form.analyze.telegram.test.ts
|
||||
ui/src/ui/theme-variants.browser.test.ts
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||
ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -4,6 +4,36 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Android/chat settings: redesign the chat settings sheet with grouped device and media sections, refresh the Connect and Voice tabs, and tighten the chat composer/session header for a denser mobile layout. (#44894) Thanks @obviyus.
|
||||
- Docker/timezone override: add `OPENCLAW_TZ` so `docker-setup.sh` can pin gateway and CLI containers to a chosen IANA timezone instead of inheriting the daemon default. (#34119) Thanks @Lanfei.
|
||||
- iOS/onboarding: add a first-run welcome pager before gateway setup, stop auto-opening the QR scanner, and show `/pair qr` instructions on the connect step. (#45054) Thanks @ngutman.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.
|
||||
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||
- Windows/gateway auth: stop attaching device identity on local loopback shared-token and password gateway calls, so native Windows agent replies no longer log stale `device signature expired` fallback noise before succeeding.
|
||||
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
|
||||
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
|
||||
- Gateway/session reset: preserve `lastAccountId` and `lastThreadId` across gateway session resets so replies keep routing back to the same account and thread after `/reset`. (#44773) Thanks @Lanfei.
|
||||
- Agents/memory bootstrap: load only one root memory file, preferring `MEMORY.md` and using `memory.md` as a fallback, so case-insensitive Docker mounts no longer inject duplicate memory context. (#26054) Thanks @Lanfei.
|
||||
- Agents/OpenAI-compatible compat overrides: respect explicit user `models[].compat` opt-ins for non-native `openai-completions` endpoints so usage-in-streaming capability overrides no longer get forced off when the endpoint actually supports them. (#44432) Thanks @cheapestinference.
|
||||
- Agents/Azure OpenAI startup prompts: rephrase the built-in `/new`, `/reset`, and post-compaction startup instruction so Azure OpenAI deployments no longer hit HTTP 400 false positives from the content filter. (#43403) Thanks @xingsy97.
|
||||
- Config/validation: accept documented `agents.list[].params` per-agent overrides in strict config validation so `openclaw config validate` no longer rejects runtime-supported `cacheRetention`, `temperature`, and `maxTokens` settings. (#41171) Thanks @atian8179.
|
||||
- Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.
|
||||
- Config/web fetch: restore runtime validation for documented `tools.web.fetch.readability` and `tools.web.fetch.firecrawl` settings so valid web fetch configs no longer fail with unrecognized-key errors. (#42583) Thanks @stim64045-spec.
|
||||
- Signal/config validation: add `channels.signal.groups` schema support so per-group `requireMention`, `tools`, and `toolsBySender` overrides no longer get rejected during config validation. (#27199) Thanks @unisone.
|
||||
- Config/discovery: accept `discovery.wideArea.domain` in strict config validation so unicast DNS-SD gateway configs no longer fail with an unrecognized-key error. (#35615) Thanks @ingyukoh.
|
||||
- Security/exec approvals: unwrap more `pnpm` runtime forms during approval binding, including `pnpm --reporter ... exec` and direct `pnpm node` file runs, with matching regression coverage and docs updates.
|
||||
- Security/exec approvals: fail closed for Perl `-M` and `-I` approval flows so preload and load-path module resolution stays outside approval-backed runtime execution unless the operator uses a broader explicit trust path.
|
||||
- Control UI/insecure auth: preserve explicit shared token and password auth on plain-HTTP Control UI connects so LAN and reverse-proxy sessions no longer drop shared auth before the first WebSocket handshake. (#45088) Thanks @velvet-shark.
|
||||
- macOS/onboarding: avoid self-restarting freshly bootstrapped launchd gateways and give new daemon installs longer to become healthy, so `openclaw onboard --install-daemon` no longer false-fails on slower Macs and fresh VM snapshots.
|
||||
- Agents/compaction: preserve safeguard compaction summary language continuity via default and configurable custom instructions so persona drift is reduced after auto-compaction. (#10456) Thanks @keepitmello.
|
||||
- Agents/tool warnings: distinguish gated core tools like `apply_patch` from plugin-only unknown entries in `tools.profile` warnings, so unavailable core tools now report current runtime/provider/model/config gating instead of suggesting a missing plugin.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
@@ -85,6 +115,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.
|
||||
- Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.
|
||||
- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
|
||||
- Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.
|
||||
- Delivery/dedupe: trim completed direct-cron delivery cache correctly and keep mirrored transcript dedupe active even when transcript files contain malformed lines. (#44666) thanks @frankekn.
|
||||
- CLI/thinking help: add the missing `xhigh` level hints to `openclaw cron add`, `openclaw cron edit`, and `openclaw agent` so the help text matches the levels already accepted at runtime. (#44819) Thanks @kiki830621.
|
||||
- Agents/Anthropic replay: drop replayed assistant thinking blocks for native Anthropic and Bedrock Claude providers so persisted follow-up turns no longer fail on stored thinking blocks. (#44843) Thanks @jmcte.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
@@ -303,6 +337,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||
- Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym.
|
||||
- Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz.
|
||||
- Agents/sessions_spawn: use the target agent workspace for cross-agent spawned runs instead of inheriting the caller workspace, so child sessions load the correct workspace-scoped instructions and persona files. (#40176) Thanks @moshehbenavraham.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Welcome to the lobster tank! 🦞
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
- **Ayaan Zaidi** - Telegram subsystem, iOS app
|
||||
- **Ayaan Zaidi** - Telegram subsystem, Android app
|
||||
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
|
||||
|
||||
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
|
||||
|
||||
312
appcast.xml
312
appcast.xml
@@ -2,6 +2,98 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.3.12</title>
|
||||
<pubDate>Fri, 13 Mar 2026 04:25:50 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026031290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.12</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.12</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.</li>
|
||||
<li>OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across <code>/fast</code>, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.</li>
|
||||
<li>Anthropic/Claude fast mode: map the shared <code>/fast</code> toggle and <code>params.fastMode</code> to direct Anthropic API-key <code>service_tier</code> requests, with live verification for both Anthropic and OpenAI fast-mode tiers.</li>
|
||||
<li>Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.</li>
|
||||
<li>Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi</li>
|
||||
<li>Agents/subagents: add <code>sessions_yield</code> so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff</li>
|
||||
<li>Slack/agent replies: support <code>channelData.slack.blocks</code> in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Security/device pairing: switch <code>/pair</code> and <code>openclaw qr</code> setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.</li>
|
||||
<li>Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (<code>GHSA-99qw-6mr3-36qr</code>)(#44174) Thanks @lintsinghua and @vincentkoc.</li>
|
||||
<li>Models/Kimi Coding: send <code>anthropic-messages</code> tools in native Anthropic format again so <code>kimi-coding</code> stops degrading tool calls into XML/plain-text pseudo invocations instead of real <code>tool_use</code> blocks. (#38669, #39907, #40552) Thanks @opriz.</li>
|
||||
<li>TUI/chat log: reuse the active assistant message component for the same streaming run so <code>openclaw tui</code> no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.</li>
|
||||
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
|
||||
<li>Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.</li>
|
||||
<li>Models/Kimi Coding: send the built-in <code>User-Agent: claude-code/0.1.0</code> header by default for <code>kimi-coding</code> while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.</li>
|
||||
<li>Models/OpenAI Codex Spark: keep <code>gpt-5.3-codex-spark</code> working on the <code>openai-codex/*</code> path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct <code>openai/*</code> Spark row that OpenAI rejects live.</li>
|
||||
<li>Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like <code>kimi-k2.5:cloud</code>, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.</li>
|
||||
<li>Moonshot CN API: respect explicit <code>baseUrl</code> (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.</li>
|
||||
<li>Kimi Coding/provider config: respect explicit <code>models.providers["kimi-coding"].baseUrl</code> when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.</li>
|
||||
<li>Gateway/main-session routing: keep TUI and other <code>mode:UI</code> main-session sends on the internal surface when <code>deliver</code> is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.</li>
|
||||
<li>BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching <code>fromMe</code> event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.</li>
|
||||
<li>iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching <code>is_from_me</code> event was just seen for the same chat, text, and <code>created_at</code>, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.</li>
|
||||
<li>Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.</li>
|
||||
<li>Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding <code>replyToId</code> from the block reply dedup key and adding an explicit <code>threading</code> dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.</li>
|
||||
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
|
||||
<li>macOS/Reminders: add the missing <code>NSRemindersUsageDescription</code> to the bundled app so <code>apple-reminders</code> can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.</li>
|
||||
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
|
||||
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
|
||||
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
|
||||
<li>Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so <code>openclaw update</code> no longer dies early on missing <code>git</code> or <code>node-llama-cpp</code> download setup.</li>
|
||||
<li>Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed <code>write</code> no longer reports success while creating empty files. (#43876) Thanks @glitch418x.</li>
|
||||
<li>Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible <code>\u{...}</code> escapes instead of spoofing the reviewed command. (<code>GHSA-pcqg-f7rg-xfvv</code>)(#43687) Thanks @EkiXu and @vincentkoc.</li>
|
||||
<li>Hooks/loader: fail closed when workspace hook paths cannot be resolved with <code>realpath</code>, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.</li>
|
||||
<li>Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.</li>
|
||||
<li>Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (<code>GHSA-9r3v-37xh-2cf6</code>)(#44091) Thanks @wooluo and @vincentkoc.</li>
|
||||
<li>Security/exec allowlist: preserve POSIX case sensitivity and keep <code>?</code> within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (<code>GHSA-f8r2-vg7x-gh8m</code>)(#43798) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/commands: require sender ownership for <code>/config</code> and <code>/debug</code> so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (<code>GHSA-r7vr-gr74-94p8</code>)(#44305) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (<code>GHSA-rqpp-rjj8-7wv8</code>)(#44306) Thanks @LUOYEcode and @vincentkoc.</li>
|
||||
<li>Security/browser.request: block persistent browser profile create/delete routes from write-scoped <code>browser.request</code> so callers can no longer persist admin-only browser profile changes through the browser control surface. (<code>GHSA-vmhq-cqm9-6p7q</code>)(#43800) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external <code>agent</code> callers can no longer override the gateway workspace boundary. (<code>GHSA-2rqg-gjgv-84jm</code>)(#43801) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via <code>session_status</code>. (<code>GHSA-wcxr-59v9-rxr8</code>)(#43754) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/agent tools: mark <code>nodes</code> as explicitly owner-only and document/test that <code>canvas</code> remains a shared trusted-operator surface unless a real boundary bypass exists.</li>
|
||||
<li>Security/exec approvals: fail closed for Ruby approval flows that use <code>-r</code>, <code>--require</code>, or <code>-I</code> so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.</li>
|
||||
<li>Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (<code>GHSA-2pwv-x786-56f8</code>)(#43686) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Docs/onboarding: align the legacy wizard reference and <code>openclaw onboard</code> command docs with the Ollama onboarding flow so all onboarding reference paths now document <code>--auth-choice ollama</code>, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.</li>
|
||||
<li>Models/secrets: enforce source-managed SecretRef markers in generated <code>models.json</code> so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.</li>
|
||||
<li>Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (<code>GHSA-jv4g-m82p-2j93</code>)(#44089) (<code>GHSA-xwx2-ppv2-wx98</code>)(#44089) Thanks @ez-lbz and @vincentkoc.</li>
|
||||
<li>Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (<code>GHSA-6rph-mmhp-h7h9</code>)(#43684) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Security/host env: block inherited <code>GIT_EXEC_PATH</code> from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (<code>GHSA-jf5v-pqgw-gm5m</code>)(#43685) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/Feishu webhook: require <code>encryptKey</code> alongside <code>verificationToken</code> in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (<code>GHSA-g353-mgv3-8pcj</code>)(#44087) Thanks @lintsinghua and @vincentkoc.</li>
|
||||
<li>Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic <code>p2p</code> reactions. (<code>GHSA-m69h-jm2f-2pv8</code>)(#44088) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a <code>200</code> response. (<code>GHSA-mhxh-9pjm-w7q5</code>)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.</li>
|
||||
<li>Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth <code>429</code> responses. (<code>GHSA-5m9r-p9g7-679c</code>)(#44173) Thanks @zpbrent and @vincentkoc.</li>
|
||||
<li>Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind <code>channels.zalouser.dangerouslyAllowNameMatching</code>. Thanks @zpbrent.</li>
|
||||
<li>Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's <code>dangerouslyAllowNameMatching</code> break-glass flag.</li>
|
||||
<li>Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap <code>pnpm</code>/<code>npm exec</code>/<code>npx</code> script runners before approval binding. (<code>GHSA-57jw-9722-6rf2</code>)(<code>GHSA-jvqh-rfmh-jh27</code>)(<code>GHSA-x7pp-23xv-mmr4</code>)(<code>GHSA-jc5j-vg4r-j5jx</code>)(#44247) Thanks @tdjackey and @vincentkoc.</li>
|
||||
<li>Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.</li>
|
||||
<li>Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.</li>
|
||||
<li>Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.</li>
|
||||
<li>Context engine/session routing: forward optional <code>sessionKey</code> through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.</li>
|
||||
<li>Agents/failover: classify z.ai <code>network_error</code> stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.</li>
|
||||
<li>Memory/session sync: add mode-aware post-compaction session reindexing with <code>agents.defaults.compaction.postIndexSync</code> plus <code>agents.defaults.memorySearch.sync.sessions.postCompactionForce</code>, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.</li>
|
||||
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
|
||||
<li>Telegram/native command sync: suppress expected <code>BOT_COMMANDS_TOO_MUCH</code> retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.</li>
|
||||
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
|
||||
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
|
||||
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
|
||||
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
|
||||
<li>Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when <code>hooks.allowedAgentIds</code> leaves hook routing unrestricted.</li>
|
||||
<li>Agents/compaction: skip the post-compaction <code>cache-ttl</code> marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.</li>
|
||||
<li>Native chat/macOS: add <code>/new</code>, <code>/reset</code>, and <code>/clear</code> reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.</li>
|
||||
<li>Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.</li>
|
||||
<li>Cron/doctor: stop flagging canonical <code>agentTurn</code> and <code>systemEvent</code> payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.</li>
|
||||
<li>ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving <code>end_turn</code>, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.</li>
|
||||
<li>Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.12/OpenClaw-2026.3.12.zip" length="23628700" type="application/octet-stream" sparkle:edSignature="o6Zdcw36l3I0jUg14H+RBqNwrhuuSsq1WMDi4tBRa1+5TC3VCVdFKZ2hzmH2Xjru9lDEzVMP8v2A6RexSbOCBQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.8-beta.1</title>
|
||||
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
|
||||
@@ -438,225 +530,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.3.2</title>
|
||||
<pubDate>Tue, 03 Mar 2026 04:30:29 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026030290</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.3.2</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.3.2</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, <code>openclaw secrets</code> planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.</li>
|
||||
<li>Tools/PDF analysis: add a first-class <code>pdf</code> tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (<code>agents.defaults.pdfModel</code>, <code>pdfMaxBytesMb</code>, <code>pdfMaxPages</code>), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.</li>
|
||||
<li>Outbound adapters/plugins: add shared <code>sendPayload</code> support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.</li>
|
||||
<li>Models/MiniMax: add first-class <code>MiniMax-M2.5-highspeed</code> support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy <code>MiniMax-M2.5-Lightning</code> compatibility for existing configs.</li>
|
||||
<li>Sessions/Attachments: add inline file attachment support for <code>sessions_spawn</code> (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via <code>tools.sessions_spawn.attachments</code>. (#16761) Thanks @napetrov.</li>
|
||||
<li>Telegram/Streaming defaults: default <code>channels.telegram.streaming</code> to <code>partial</code> (from <code>off</code>) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.</li>
|
||||
<li>Telegram/DM streaming: use <code>sendMessageDraft</code> for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.</li>
|
||||
<li>Telegram/voice mention gating: add optional <code>disableAudioPreflight</code> on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.</li>
|
||||
<li>CLI/Config validation: add <code>openclaw config validate</code> (with <code>--json</code>) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.</li>
|
||||
<li>Tools/Diffs: add PDF file output support and rendering quality customization controls (<code>fileQuality</code>, <code>fileScale</code>, <code>fileMaxWidth</code>) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.</li>
|
||||
<li>Memory/Ollama embeddings: add <code>memorySearch.provider = "ollama"</code> and <code>memorySearch.fallback = "ollama"</code> support, honor <code>models.providers.ollama</code> settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.</li>
|
||||
<li>Zalo Personal plugin (<code>@openclaw/zalouser</code>): rebuilt channel runtime to use native <code>zca-js</code> integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.</li>
|
||||
<li>Plugin SDK/channel extensibility: expose <code>channelRuntime</code> on <code>ChannelGatewayContext</code> so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.</li>
|
||||
<li>Plugin runtime/STT: add <code>api.runtime.stt.transcribeAudioFile(...)</code> so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.</li>
|
||||
<li>Plugin hooks/session lifecycle: include <code>sessionKey</code> in <code>session_start</code>/<code>session_end</code> hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.</li>
|
||||
<li>Hooks/message lifecycle: add internal hook events <code>message:transcribed</code> and <code>message:preprocessed</code>, plus richer outbound <code>message:sent</code> context (<code>isGroup</code>, <code>groupId</code>) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.</li>
|
||||
<li>Media understanding/audio echo: add optional <code>tools.media.audio.echoTranscript</code> + <code>echoFormat</code> to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.</li>
|
||||
<li>Plugin runtime/system: expose <code>runtime.system.requestHeartbeatNow(...)</code> so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.</li>
|
||||
<li>Plugin runtime/events: expose <code>runtime.events.onAgentEvent</code> and <code>runtime.events.onSessionTranscriptUpdate</code> for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.</li>
|
||||
<li>CLI/Banner taglines: add <code>cli.banner.taglineMode</code> (<code>random</code> | <code>default</code> | <code>off</code>) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Onboarding now defaults <code>tools.profile</code> to <code>messaging</code> for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.</li>
|
||||
<li><strong>BREAKING:</strong> ACP dispatch now defaults to enabled unless explicitly disabled (<code>acp.dispatch.enabled=false</code>). If you need to pause ACP turn routing while keeping <code>/acp</code> controls, set <code>acp.dispatch.enabled=false</code>. Docs: https://docs.openclaw.ai/tools/acp-agents</li>
|
||||
<li><strong>BREAKING:</strong> Plugin SDK removed <code>api.registerHttpHandler(...)</code>. Plugins must register explicit HTTP routes via <code>api.registerHttpRoute({ path, auth, match, handler })</code>, and dynamic webhook lifecycles should use <code>registerPluginHttpRoute(...)</code>.</li>
|
||||
<li><strong>BREAKING:</strong> Zalo Personal plugin (<code>@openclaw/zalouser</code>) no longer depends on external <code>zca</code>-compatible CLI binaries (<code>openzca</code>, <code>zca-cli</code>) for runtime send/listen/login; operators should use <code>openclaw channels login --channel zalouser</code> after upgrade to refresh sessions in the new JS-native path.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (<code>trim</code> on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing <code>token.trim()</code> crashes during status/start flows. (#31973) Thanks @ningding97.</li>
|
||||
<li>Discord/lifecycle startup status: push an immediate <code>connected</code> status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.</li>
|
||||
<li>Feishu/LINE group system prompts: forward per-group <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.</li>
|
||||
<li>Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.</li>
|
||||
<li>Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older <code>openclaw/plugin-sdk</code> builds omit webhook default constants. (#31606)</li>
|
||||
<li>Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.</li>
|
||||
<li>Gateway/Subagent TLS pairing: allow authenticated local <code>gateway-client</code> backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring <code>sessions_spawn</code> with <code>gateway.tls.enabled=true</code> in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.</li>
|
||||
<li>Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)</li>
|
||||
<li>Voice-call/runtime lifecycle: prevent <code>EADDRINUSE</code> loops by resetting failed runtime promises, making webhook <code>start()</code> idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when <code>gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback</code> accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example <code>(a|aa)+</code>), and bound large regex-evaluation inputs for session-filter and log-redaction paths.</li>
|
||||
<li>Gateway/Plugin HTTP hardening: require explicit <code>auth</code> for plugin route registration, add route ownership guards for duplicate <code>path+match</code> registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.</li>
|
||||
<li>Browser/Profile defaults: prefer <code>openclaw</code> profile over <code>chrome</code> in headless/no-sandbox environments unless an explicit <code>defaultProfile</code> is configured. (#14944) Thanks @BenediktSchackenberg.</li>
|
||||
<li>Gateway/WS security: keep plaintext <code>ws://</code> loopback-only by default, with explicit break-glass private-network opt-in via <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code>; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.</li>
|
||||
<li>OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit <code>doctor --deep</code>) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.</li>
|
||||
<li>Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.</li>
|
||||
<li>CLI/Config validation and routing hardening: dedupe <code>openclaw config validate</code> failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including <code>--json</code> fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed <code>config get/unset</code> with split root options). Thanks @gumadeiras.</li>
|
||||
<li>Browser/Extension relay reconnect tolerance: keep <code>/json/version</code> and <code>/cdp</code> reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.</li>
|
||||
<li>CLI/Browser start timeout: honor <code>openclaw browser --timeout <ms> start</code> and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.</li>
|
||||
<li>Synology Chat/gateway lifecycle: keep <code>startAccount</code> pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.</li>
|
||||
<li>Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like <code>/usr/bin/g++</code> and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.</li>
|
||||
<li>Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with <code>204</code> to avoid persistent <code>Processing...</code> states in Synology Chat clients. (#26635) Thanks @memphislee09-source.</li>
|
||||
<li>Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.</li>
|
||||
<li>Slack/Bolt startup compatibility: remove invalid <code>message.channels</code> and <code>message.groups</code> event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified <code>message</code> handler (<code>channel_type</code>). (#32033) Thanks @mahopan.</li>
|
||||
<li>Slack/socket auth failure handling: fail fast on non-recoverable auth errors (<code>account_inactive</code>, <code>invalid_auth</code>, etc.) during startup and reconnect instead of retry-looping indefinitely, including <code>unable_to_socket_mode_start</code> error payload propagation. (#32377) Thanks @scoootscooob.</li>
|
||||
<li>Gateway/macOS LaunchAgent hardening: write <code>Umask=077</code> in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
||||
<li>macOS/LaunchAgent security defaults: write <code>Umask=63</code> (octal <code>077</code>) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system <code>022</code>. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from <code>HTTPS_PROXY</code>/<code>HTTP_PROXY</code> env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.</li>
|
||||
<li>Sandbox/workspace mount permissions: make primary <code>/workspace</code> bind mounts read-only whenever <code>workspaceAccess</code> is not <code>rw</code> (including <code>none</code>) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.</li>
|
||||
<li>Tools/fsPolicy propagation: honor <code>tools.fs.workspaceOnly</code> for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.</li>
|
||||
<li>Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like <code>node@22</code>) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.</li>
|
||||
<li>Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.</li>
|
||||
<li>Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded <code>/api/channels/*</code> variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.</li>
|
||||
<li>Browser/Gateway hardening: preserve env credentials for <code>OPENCLAW_GATEWAY_URL</code> / <code>CLAWDBOT_GATEWAY_URL</code> while treating explicit <code>--url</code> as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.</li>
|
||||
<li>Gateway/Control UI basePath webhook passthrough: let non-read methods under configured <code>controlUiBasePath</code> fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.</li>
|
||||
<li>Control UI/Legacy browser compatibility: replace <code>toSorted</code>-dependent cron suggestion sorting in <code>app-render</code> with a compatibility helper so older browsers without <code>Array.prototype.toSorted</code> no longer white-screen. (#31775) Thanks @liuxiaopai-ai.</li>
|
||||
<li>macOS/PeekabooBridge: add compatibility socket symlinks for legacy <code>clawdbot</code>, <code>clawdis</code>, and <code>moltbot</code> Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.</li>
|
||||
<li>Gateway/message tool reliability: avoid false <code>Unknown channel</code> failures when <code>message.*</code> actions receive platform-specific channel ids by falling back to <code>toolContext.currentChannelProvider</code>, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.</li>
|
||||
<li>Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for <code>.cmd</code> shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.</li>
|
||||
<li>Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for <code>sessions_spawn</code> with <code>runtime="acp"</code> by rejecting ACP spawns from sandboxed requester sessions and rejecting <code>sandbox="require"</code> for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.</li>
|
||||
<li>Security/Web tools SSRF guard: keep DNS pinning for untrusted <code>web_fetch</code> and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.</li>
|
||||
<li>Gemini schema sanitization: coerce malformed JSON Schema <code>properties</code> values (<code>null</code>, arrays, primitives) to <code>{}</code> before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.</li>
|
||||
<li>Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.</li>
|
||||
<li>Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.</li>
|
||||
<li>Browser/Extension relay stale tabs: evict stale cached targets from <code>/json/list</code> when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.</li>
|
||||
<li>Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up <code>PortInUseError</code> races after <code>browser start</code>/<code>open</code>. (#29538) Thanks @AaronWander.</li>
|
||||
<li>OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty <code>function_call_output.call_id</code> payloads in the WS conversion path to avoid OpenAI 400 errors (<code>Invalid 'input[n].call_id': empty string</code>), with regression coverage for both inbound stream normalization and outbound payload guards.</li>
|
||||
<li>Security/Nodes camera URL downloads: bind node <code>camera.snap</code>/<code>camera.clip</code> URL payload downloads to the resolved node host, enforce fail-closed behavior when node <code>remoteIp</code> is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.</li>
|
||||
<li>Config/backups hardening: enforce owner-only (<code>0600</code>) permissions on rotated config backups and clean orphan <code>.bak.*</code> files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.</li>
|
||||
<li>Telegram/inbound media filenames: preserve original <code>file_name</code> metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.</li>
|
||||
<li>Gateway/OpenAI chat completions: honor <code>x-openclaw-message-channel</code> when building <code>agentCommand</code> input for <code>/v1/chat/completions</code>, preserving caller channel identity instead of forcing <code>webchat</code>. (#30462) Thanks @bmendonca3.</li>
|
||||
<li>Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.</li>
|
||||
<li>Media/MIME normalization: normalize parameterized/case-variant MIME strings in <code>kindFromMime</code> (for example <code>Audio/Ogg; codecs=opus</code>) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.</li>
|
||||
<li>Discord/audio preflight mentions: detect audio attachments via Discord <code>content_type</code> and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.</li>
|
||||
<li>Feishu/topic session routing: use <code>thread_id</code> as topic session scope fallback when <code>root_id</code> is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.</li>
|
||||
<li>Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of <code>NO_REPLY</code> and keep final-message buffering in sync, preventing partial <code>NO</code> leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.</li>
|
||||
<li>Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.</li>
|
||||
<li>Voice-call/Twilio external outbound: auto-register webhook-first <code>outbound-api</code> calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/topic root replies: prefer <code>root_id</code> as outbound <code>replyTargetMessageId</code> when present, and parse millisecond <code>message_create_time</code> values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/DM pairing reply target: send pairing challenge replies to <code>chat:<chat_id></code> instead of <code>user:<sender_open_id></code> so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.</li>
|
||||
<li>Feishu/Lark private DM routing: treat inbound <code>chat_type: "private"</code> as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.</li>
|
||||
<li>Signal/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.</li>
|
||||
<li>Discord/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.</li>
|
||||
<li>Synology Chat/reply delivery: resolve webhook usernames to Chat API <code>user_id</code> values for outbound chatbot replies, avoiding mismatches between webhook user IDs and <code>method=chatbot</code> recipient IDs in multi-account setups. (#23709) Thanks @druide67.</li>
|
||||
<li>Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.</li>
|
||||
<li>Slack/session routing: keep top-level channel messages in one shared session when <code>replyToMode=off</code>, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.</li>
|
||||
<li>Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.</li>
|
||||
<li>Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.</li>
|
||||
<li>Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (<code>monitor.account-scope.test.ts</code>) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Send target prefixes: normalize explicit <code>group:</code>/<code>dm:</code> send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Webchat/Feishu session continuation: preserve routable <code>OriginatingChannel</code>/<code>OriginatingTo</code> metadata from session delivery context in <code>chat.send</code>, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)</li>
|
||||
<li>Telegram/implicit mention forum handling: exclude Telegram forum system service messages (<code>forum_topic_*</code>, <code>general_forum_topic_*</code>) from reply-chain implicit mention detection so <code>requireMention</code> does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.</li>
|
||||
<li>Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.</li>
|
||||
<li>Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (<code>provider: "message"</code>) and normalize <code>lark</code>/<code>feishu</code> provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)</li>
|
||||
<li>Webchat/silent token leak: filter assistant <code>NO_REPLY</code>-only transcript entries from <code>chat.history</code> responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.</li>
|
||||
<li>Doctor/local memory provider checks: stop false-positive local-provider warnings when <code>provider=local</code> and no explicit <code>modelPath</code> is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.</li>
|
||||
<li>Media understanding/parakeet CLI output parsing: read <code>parakeet-mlx</code> transcripts from <code>--output-dir/<media-basename>.txt</code> when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.</li>
|
||||
<li>Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.</li>
|
||||
<li>Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Node browser proxy routing: honor <code>profile</code> from <code>browser.request</code> JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.</li>
|
||||
<li>Gateway/Control UI basePath POST handling: return 405 for <code>POST</code> on exact basePath routes (for example <code>/openclaw</code>) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.</li>
|
||||
<li>Browser/default profile selection: default <code>browser.defaultProfile</code> behavior now prefers <code>openclaw</code> (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the <code>chrome</code> relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.</li>
|
||||
<li>Models/config env propagation: apply <code>config.env.vars</code> before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.</li>
|
||||
<li>Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so <code>openclaw models status</code> no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.</li>
|
||||
<li>Gateway/Heartbeat model reload: treat <code>models.*</code> and <code>agents.defaults.model</code> config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.</li>
|
||||
<li>Memory/LanceDB embeddings: forward configured <code>embedding.dimensions</code> into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.</li>
|
||||
<li>Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.</li>
|
||||
<li>Browser/CDP status accuracy: require a successful <code>Browser.getVersion</code> response over the CDP websocket (not just socket-open) before reporting <code>cdpReady</code>, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.</li>
|
||||
<li>Daemon/systemd checks in containers: treat missing <code>systemctl</code> invocations (including <code>spawn systemctl ENOENT</code>/<code>EACCES</code>) as unavailable service state during <code>is-enabled</code> checks, preventing container flows from failing with <code>Gateway service check failed</code> before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.</li>
|
||||
<li>Security/Node exec approvals: revalidate approval-bound <code>cwd</code> identity immediately before execution/forwarding and fail closed with an explicit denial when <code>cwd</code> drifts after approval hardening.</li>
|
||||
<li>Security audit/skills workspace hardening: add <code>skills.workspace.symlink_escape</code> warning in <code>openclaw security audit</code> when workspace <code>skills/**/SKILL.md</code> resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.</li>
|
||||
<li>Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example <code>env sh -c ...</code>) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.</li>
|
||||
<li>Security/fs-safe write hardening: make <code>writeFileWithinRoot</code> use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.</li>
|
||||
<li>Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.</li>
|
||||
<li>Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like <code>[System Message]</code> and line-leading <code>System:</code> in untrusted message content. (#30448)</li>
|
||||
<li>Sandbox/Docker setup command parsing: accept <code>agents.*.sandbox.docker.setupCommand</code> as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction <code>AGENTS.md</code> context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.</li>
|
||||
<li>Agents/Sandbox workdir mapping: map container workdir paths (for example <code>/workspace</code>) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Docker/Sandbox bootstrap hardening: make <code>OPENCLAW_SANDBOX</code> opt-in parsing explicit (<code>1|true|yes|on</code>), support custom Docker socket paths via <code>OPENCLAW_DOCKER_SOCKET</code>, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to <code>off</code> when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.</li>
|
||||
<li>Hooks/webhook ACK compatibility: return <code>200</code> (instead of <code>202</code>) for successful <code>/hooks/agent</code> requests so providers that require <code>200</code> (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.</li>
|
||||
<li>Feishu/Run channel fallback: prefer <code>Provider</code> over <code>Surface</code> when inferring queued run <code>messageProvider</code> fallback (when <code>OriginatingChannel</code> is missing), preventing Feishu turns from being mislabeled as <code>webchat</code> in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.</li>
|
||||
<li>Skills/sherpa-onnx-tts: run the <code>sherpa-onnx-tts</code> bin under ESM (replace CommonJS <code>require</code> imports) and add regression coverage to prevent <code>require is not defined in ES module scope</code> startup crashes. (#31965) Thanks @bmendonca3.</li>
|
||||
<li>Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.</li>
|
||||
<li>Slack/Channel message subscriptions: register explicit <code>message.channels</code> and <code>message.groups</code> monitor handlers (alongside generic <code>message</code>) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.</li>
|
||||
<li>Hooks/session-scoped memory context: expose ephemeral <code>sessionId</code> in embedded plugin tool contexts and <code>before_tool_call</code>/<code>after_tool_call</code> hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across <code>/new</code> and <code>/reset</code>. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.</li>
|
||||
<li>Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.</li>
|
||||
<li>Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.</li>
|
||||
<li>Feishu/File upload filenames: percent-encode non-ASCII/special-character <code>file_name</code> values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.</li>
|
||||
<li>Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized <code>kindFromMime</code> so mixed-case/parameterized MIME values classify consistently across message channels.</li>
|
||||
<li>WhatsApp/inbound self-message context: propagate inbound <code>fromMe</code> through the web inbox pipeline and annotate direct self messages as <code>(self)</code> in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.</li>
|
||||
<li>Webchat/stream finalization: persist streamed assistant text when final events omit <code>message</code>, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)</li>
|
||||
<li>Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)</li>
|
||||
<li>Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)</li>
|
||||
<li>Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)</li>
|
||||
<li>Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured <code>LarkApiError</code> responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)</li>
|
||||
<li>Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (<code>contact:contact.base:readonly</code>) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)</li>
|
||||
<li>BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound <code>message_id</code> selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.</li>
|
||||
<li>WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.</li>
|
||||
<li>Feishu/default account resolution: always honor explicit <code>channels.feishu.defaultAccount</code> during outbound account selection (including top-level-credential setups where the preferred id is not present in <code>accounts</code>), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.</li>
|
||||
<li>Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (<code>contact:contact.base:readonly</code>) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)</li>
|
||||
<li>Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.</li>
|
||||
<li>Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)</li>
|
||||
<li>Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.</li>
|
||||
<li>Browser/Extension re-announce reliability: keep relay state in <code>connecting</code> when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.</li>
|
||||
<li>Browser/Act request compatibility: accept legacy flattened <code>action="act"</code> params (<code>kind/ref/text/...</code>) in addition to <code>request={...}</code> so browser act calls no longer fail with <code>request required</code>. (#15120) Thanks @vincentkoc.</li>
|
||||
<li>OpenRouter/x-ai compatibility: skip <code>reasoning.effort</code> injection for <code>x-ai/*</code> models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.</li>
|
||||
<li>Models/openai-completions developer-role compatibility: force <code>supportsDeveloperRole=false</code> for non-native endpoints, treat unparseable <code>baseUrl</code> values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.</li>
|
||||
<li>Browser/Profile attach-only override: support <code>browser.profiles.<name>.attachOnly</code> (fallback to global <code>browser.attachOnly</code>) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.</li>
|
||||
<li>Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file <code>starttime</code> with <code>/proc/<pid>/stat</code> starttime, so stale <code>.jsonl.lock</code> files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.</li>
|
||||
<li>Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel <code>resolveDefaultTo</code> fallback) when <code>delivery.to</code> is omitted. (#32364) Thanks @hclsys.</li>
|
||||
<li>OpenAI media capabilities: include <code>audio</code> in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.</li>
|
||||
<li>Browser/Managed tab cap: limit loopback managed <code>openclaw</code> page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.</li>
|
||||
<li>Docker/Image health checks: add Dockerfile <code>HEALTHCHECK</code> that probes gateway <code>GET /healthz</code> so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.</li>
|
||||
<li>Gateway/Node dangerous-command parity: include <code>sms.send</code> in default onboarding node <code>denyCommands</code>, share onboarding deny defaults with the gateway dangerous-command source of truth, and include <code>sms.send</code> in phone-control <code>/phone arm writes</code> handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.</li>
|
||||
<li>Pairing/AllowFrom account fallback: handle omitted <code>accountId</code> values in <code>readChannelAllowFromStore</code> and <code>readChannelAllowFromStoreSync</code> as <code>default</code>, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.</li>
|
||||
<li>Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.</li>
|
||||
<li>Browser/CDP proxy bypass: force direct loopback agent paths and scoped <code>NO_PROXY</code> expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Sessions/idle reset correctness: preserve existing <code>updatedAt</code> during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.</li>
|
||||
<li>Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing <code>starttime</code> when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.</li>
|
||||
<li>Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (<code>mtimeMs</code> + <code>sizeBytes</code>), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.</li>
|
||||
<li>Agents/Subagents <code>sessions_spawn</code>: reject malformed <code>agentId</code> inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.</li>
|
||||
<li>CLI/installer Node preflight: enforce Node.js <code>v22.12+</code> consistently in both <code>openclaw.mjs</code> runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.</li>
|
||||
<li>Web UI/config form: support SecretInput string-or-secret-ref unions in map <code>additionalProperties</code>, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.</li>
|
||||
<li>Auto-reply/inline command cleanup: preserve newline structure when stripping inline <code>/status</code> and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.</li>
|
||||
<li>Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like <code>source</code>/<code>provider</code>), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.</li>
|
||||
<li>Hooks/runtime stability: keep the internal hook handler registry on a <code>globalThis</code> singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.</li>
|
||||
<li>Hooks/after_tool_call: include embedded session context (<code>sessionKey</code>, <code>agentId</code>) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.</li>
|
||||
<li>Hooks/tool-call correlation: include <code>runId</code> and <code>toolCallId</code> in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in <code>before_tool_call</code> and <code>after_tool_call</code>. (#32360) Thanks @vincentkoc.</li>
|
||||
<li>Plugins/install diagnostics: reject legacy plugin package shapes without <code>openclaw.extensions</code> and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Hooks/plugin context parity: ensure <code>llm_input</code> hooks in embedded attempts receive the same <code>trigger</code> and <code>channelId</code>-aware <code>hookCtx</code> used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.</li>
|
||||
<li>Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (<code>pnpm</code>, <code>bun</code>) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.</li>
|
||||
<li>Cron/session reaper reliability: move cron session reaper sweeps into <code>onTimer</code> <code>finally</code> and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.</li>
|
||||
<li>Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so <code>HEARTBEAT_OK</code> noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.</li>
|
||||
<li>Authentication: classify <code>permission_error</code> as <code>auth_permanent</code> for profile fallback. (#31324) Thanks @Sid-Qin.</li>
|
||||
<li>Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (<code>newText</code> present and <code>oldText</code> absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.</li>
|
||||
<li>Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example <code>diffs</code> -> bundled <code>@openclaw/diffs</code>), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.</li>
|
||||
<li>Web UI/inline code copy fidelity: disable forced mid-token wraps on inline <code><code></code> spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.</li>
|
||||
<li>Restart sentinel formatting: avoid duplicate <code>Reason:</code> lines when restart message text already matches <code>stats.reason</code>, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.</li>
|
||||
<li>Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.</li>
|
||||
<li>Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.</li>
|
||||
<li>Failover/error classification: treat HTTP <code>529</code> (provider overloaded, common with Anthropic-compatible APIs) as <code>rate_limit</code> so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.</li>
|
||||
<li>Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.</li>
|
||||
<li>Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.</li>
|
||||
<li>Secrets/exec resolver timeout defaults: use provider <code>timeoutMs</code> as the default inactivity (<code>noOutputTimeoutMs</code>) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.</li>
|
||||
<li>Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.</li>
|
||||
<li>Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing <code>HEARTBEAT_OK</code> from being delivered to users. (#32131) Thanks @adhishthite.</li>
|
||||
<li>Cron/store migration: normalize legacy cron jobs with string <code>schedule</code> and top-level <code>command</code>/<code>timeout</code> fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.</li>
|
||||
<li>Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.</li>
|
||||
<li>Tests/Subagent announce: set <code>OPENCLAW_TEST_FAST=1</code> before importing <code>subagent-announce</code> format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
|
||||
<!-- pragma: allowlist secret -->
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -63,8 +63,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202603120
|
||||
versionName = "2026.3.12"
|
||||
versionCode = 202603130
|
||||
versionName = "2026.3.13"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -194,7 +194,7 @@ dependencies {
|
||||
implementation("androidx.camera:camera-lifecycle:1.5.2")
|
||||
implementation("androidx.camera:camera-video:1.5.2")
|
||||
implementation("androidx.camera:camera-view:1.5.2")
|
||||
implementation("com.journeyapps:zxing-android-embedded:4.3.0")
|
||||
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -18,8 +19,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -128,96 +132,142 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Connection Control", style = mobileCaption1.copy(fontWeight = FontWeight.Bold), color = mobileAccent)
|
||||
Text("Gateway Connection", style = mobileTitle1, color = mobileText)
|
||||
Text(
|
||||
"One primary action. Open advanced controls only when needed.",
|
||||
if (isConnected) "Your gateway is active and ready." else "Connect to your gateway to get started.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
// Status cards in a unified card group
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileSurface,
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Active endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = mobileAccentSoft,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(8.dp).size(18.dp),
|
||||
tint = mobileAccent,
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Endpoint", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(activeEndpoint, style = mobileBody.copy(fontFamily = FontFamily.Monospace), color = mobileText)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = if (isConnected) mobileSuccessSoft else mobileSurface,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cloud,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(8.dp).size(18.dp),
|
||||
tint = if (isConnected) mobileSuccess else mobileTextTertiary,
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Status", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(statusText, style = mobileBody, color = if (isConnected) mobileSuccess else mobileText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Gateway state", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
Text(statusText, style = mobileBody, color = mobileText)
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (isConnected) {
|
||||
if (isConnected) {
|
||||
// Outlined secondary button when connected — don't scream "danger"
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.disconnect()
|
||||
validationText = null
|
||||
return@Button
|
||||
}
|
||||
if (statusText.contains("operator offline", ignoreCase = true)) {
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
contentColor = mobileDanger,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
|
||||
) {
|
||||
Icon(Icons.Default.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Disconnect", style = mobileHeadline.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
if (statusText.contains("operator offline", ignoreCase = true)) {
|
||||
validationText = null
|
||||
viewModel.refreshGatewayConnection()
|
||||
return@Button
|
||||
}
|
||||
|
||||
val config =
|
||||
resolveGatewayConnectConfig(
|
||||
useSetupCode = inputMode == ConnectInputMode.SetupCode,
|
||||
setupCode = setupCode,
|
||||
manualHost = manualHostInput,
|
||||
manualPort = manualPortInput,
|
||||
manualTls = manualTlsInput,
|
||||
fallbackToken = gatewayToken,
|
||||
fallbackPassword = passwordInput,
|
||||
)
|
||||
|
||||
if (config == null) {
|
||||
validationText =
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
"Paste a valid setup code to connect."
|
||||
} else {
|
||||
"Enter a valid manual host and port to connect."
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
|
||||
validationText = null
|
||||
viewModel.refreshGatewayConnection()
|
||||
return@Button
|
||||
}
|
||||
|
||||
val config =
|
||||
resolveGatewayConnectConfig(
|
||||
useSetupCode = inputMode == ConnectInputMode.SetupCode,
|
||||
setupCode = setupCode,
|
||||
manualHost = manualHostInput,
|
||||
manualPort = manualPortInput,
|
||||
manualTls = manualTlsInput,
|
||||
fallbackToken = gatewayToken,
|
||||
fallbackPassword = passwordInput,
|
||||
)
|
||||
|
||||
if (config == null) {
|
||||
validationText =
|
||||
if (inputMode == ConnectInputMode.SetupCode) {
|
||||
"Paste a valid setup code to connect."
|
||||
} else {
|
||||
"Enter a valid manual host and port to connect."
|
||||
}
|
||||
return@Button
|
||||
}
|
||||
|
||||
validationText = null
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(config.host)
|
||||
viewModel.setManualPort(config.port)
|
||||
viewModel.setManualTls(config.tls)
|
||||
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||
if (config.token.isNotBlank()) {
|
||||
viewModel.setGatewayToken(config.token)
|
||||
} else if (config.bootstrapToken.isNotBlank()) {
|
||||
viewModel.setGatewayToken("")
|
||||
}
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connectManual()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (isConnected) mobileDanger else mobileAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text(primaryLabel, style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
viewModel.setManualEnabled(true)
|
||||
viewModel.setManualHost(config.host)
|
||||
viewModel.setManualPort(config.port)
|
||||
viewModel.setManualTls(config.tls)
|
||||
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||
if (config.token.isNotBlank()) {
|
||||
viewModel.setGatewayToken(config.token)
|
||||
} else if (config.bootstrapToken.isNotBlank()) {
|
||||
viewModel.setGatewayToken("")
|
||||
}
|
||||
viewModel.setGatewayPassword(config.password)
|
||||
viewModel.connectManual()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Connect Gateway", style = mobileHeadline.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
|
||||
@@ -57,8 +57,16 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ChatBubble
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -68,6 +76,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -87,8 +96,9 @@ import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
|
||||
|
||||
private enum class OnboardingStep(val index: Int, val label: String) {
|
||||
Welcome(1, "Welcome"),
|
||||
@@ -232,6 +242,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
var attemptedConnect by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val qrScannerOptions =
|
||||
remember {
|
||||
GmsBarcodeScannerOptions.Builder()
|
||||
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
|
||||
.build()
|
||||
}
|
||||
val qrScanner = remember(context, qrScannerOptions) { GmsBarcodeScanning.getClient(context, qrScannerOptions) }
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
@@ -451,23 +468,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val qrScanLauncher =
|
||||
rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||
val contents = result.contents?.trim().orEmpty()
|
||||
if (contents.isEmpty()) {
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
setupCode = scannedSetupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
}
|
||||
|
||||
if (pendingTrust != null) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
@@ -513,25 +513,20 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
"FIRST RUN",
|
||||
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp),
|
||||
color = onboardingAccent,
|
||||
)
|
||||
Text(
|
||||
"OpenClaw\nMobile Setup",
|
||||
style = onboardingDisplayStyle.copy(lineHeight = 38.sp),
|
||||
"OpenClaw",
|
||||
style = onboardingDisplayStyle,
|
||||
color = onboardingText,
|
||||
)
|
||||
Text(
|
||||
"Step ${step.index} of 4",
|
||||
style = onboardingCaption1Style,
|
||||
color = onboardingAccent,
|
||||
"Mobile Setup",
|
||||
style = onboardingTitle1Style,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
}
|
||||
StepRailWrap(current = step)
|
||||
StepRail(current = step)
|
||||
|
||||
when (step) {
|
||||
OnboardingStep.Welcome -> WelcomeStep()
|
||||
@@ -548,14 +543,28 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
gatewayError = gatewayError,
|
||||
onScanQrClick = {
|
||||
gatewayError = null
|
||||
qrScanLauncher.launch(
|
||||
ScanOptions().apply {
|
||||
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||
setPrompt("Scan OpenClaw onboarding QR")
|
||||
setBeepEnabled(false)
|
||||
setOrientationLocked(false)
|
||||
},
|
||||
)
|
||||
qrScanner.startScan()
|
||||
.addOnSuccessListener { barcode ->
|
||||
val contents = barcode.rawValue?.trim().orEmpty()
|
||||
if (contents.isEmpty()) {
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
val scannedSetupCode = resolveScannedSetupCode(contents)
|
||||
if (scannedSetupCode == null) {
|
||||
gatewayError = "QR code did not contain a valid setup code."
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
setupCode = scannedSetupCode
|
||||
gatewayInputMode = GatewayInputMode.SetupCode
|
||||
gatewayError = null
|
||||
attemptedConnect = false
|
||||
}
|
||||
.addOnCanceledListener {
|
||||
// User dismissed the scanner; preserve current form state.
|
||||
}
|
||||
.addOnFailureListener {
|
||||
gatewayError = qrScannerErrorMessage()
|
||||
}
|
||||
},
|
||||
onAdvancedOpenChange = { gatewayAdvancedOpen = it },
|
||||
onInputModeChange = {
|
||||
@@ -892,15 +901,6 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRailWrap(current: OnboardingStep) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
HorizontalDivider(color = onboardingBorder)
|
||||
StepRail(current = current)
|
||||
HorizontalDivider(color = onboardingBorder)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepRail(current: OnboardingStep) {
|
||||
val steps = OnboardingStep.entries
|
||||
@@ -942,11 +942,31 @@ private fun StepRail(current: OnboardingStep) {
|
||||
|
||||
@Composable
|
||||
private fun WelcomeStep() {
|
||||
StepShell(title = "What You Get") {
|
||||
Bullet("Control the gateway and operator chat from one mobile surface.")
|
||||
Bullet("Connect with setup code and recover pairing with CLI commands.")
|
||||
Bullet("Enable only the permissions and capabilities you want.")
|
||||
Bullet("Finish with a real connection check before entering the app.")
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
FeatureCard(
|
||||
icon = Icons.Default.Wifi,
|
||||
title = "Connect to your gateway",
|
||||
subtitle = "Scan a QR code or enter your host manually",
|
||||
accentColor = onboardingAccent,
|
||||
)
|
||||
FeatureCard(
|
||||
icon = Icons.Default.Tune,
|
||||
title = "Choose your permissions",
|
||||
subtitle = "Enable only what you need, change anytime",
|
||||
accentColor = Color(0xFF7C5AC7),
|
||||
)
|
||||
FeatureCard(
|
||||
icon = Icons.Default.ChatBubble,
|
||||
title = "Chat, voice, and screen",
|
||||
subtitle = "Full operator control from your phone",
|
||||
accentColor = onboardingSuccess,
|
||||
)
|
||||
FeatureCard(
|
||||
icon = Icons.Default.CheckCircle,
|
||||
title = "Verify your connection",
|
||||
subtitle = "Live check before you enter the app",
|
||||
accentColor = Color(0xFFC8841A),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -975,11 +995,12 @@ private fun GatewayStep(
|
||||
val manualResolvedEndpoint = remember(manualHost, manualPort, manualTls) { composeGatewayManualUrl(manualHost, manualPort, manualTls)?.let { parseGatewayEndpoint(it)?.displayUrl } }
|
||||
|
||||
StepShell(title = "Gateway Connection") {
|
||||
GuideBlock(title = "Scan onboarding QR") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
CommandBlock("openclaw qr")
|
||||
Text("Then scan with this device.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
Text(
|
||||
"Run `openclaw qr` on your gateway host, then scan the code with this device.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
CommandBlock("openclaw qr")
|
||||
Button(
|
||||
onClick = onScanQrClick,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
@@ -1023,21 +1044,6 @@ private fun GatewayStep(
|
||||
|
||||
AnimatedVisibility(visible = advancedOpen) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
GuideBlock(title = "Manual setup commands") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
CommandBlock("openclaw qr --setup-code-only")
|
||||
CommandBlock("openclaw qr --json")
|
||||
Text(
|
||||
"`--json` prints `setupCode` and `gatewayUrl`.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Auto URL discovery is not wired yet. Android emulator uses `10.0.2.2`; real devices need LAN/Tailscale host.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
}
|
||||
GatewayModeToggle(inputMode = inputMode, onInputModeChange = onInputModeChange)
|
||||
|
||||
if (inputMode == GatewayInputMode.SetupCode) {
|
||||
@@ -1306,13 +1312,9 @@ private fun StepShell(
|
||||
title: String,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
HorizontalDivider(color = onboardingBorder)
|
||||
Column(modifier = Modifier.padding(vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = onboardingTitle1Style, color = onboardingText)
|
||||
content()
|
||||
}
|
||||
HorizontalDivider(color = onboardingBorder)
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = onboardingTitle1Style, color = onboardingText)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1378,13 +1380,15 @@ private fun PermissionsStep(
|
||||
|
||||
StepShell(title = "Permissions") {
|
||||
Text(
|
||||
"Enable only what you need now. You can change everything later in Settings.",
|
||||
"Enable only what you need. You can change these anytime in Settings.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
|
||||
PermissionSectionHeader("System")
|
||||
PermissionToggleRow(
|
||||
title = "Gateway discovery",
|
||||
subtitle = if (Build.VERSION.SDK_INT >= 33) "Nearby devices" else "Location (for NSD)",
|
||||
subtitle = "Find gateways on your local network",
|
||||
checked = enableDiscovery,
|
||||
granted = isPermissionGranted(context, discoveryPermission),
|
||||
onCheckedChange = onDiscoveryChange,
|
||||
@@ -1392,7 +1396,7 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Location",
|
||||
subtitle = "location.get (while app is open)",
|
||||
subtitle = "Share device location while app is open",
|
||||
checked = enableLocation,
|
||||
granted = locationGranted,
|
||||
onCheckedChange = onLocationChange,
|
||||
@@ -1401,7 +1405,7 @@ private fun PermissionsStep(
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
PermissionToggleRow(
|
||||
title = "Notifications",
|
||||
subtitle = "system.notify and foreground alerts",
|
||||
subtitle = "Alerts and foreground service notices",
|
||||
checked = enableNotifications,
|
||||
granted = isPermissionGranted(context, Manifest.permission.POST_NOTIFICATIONS),
|
||||
onCheckedChange = onNotificationsChange,
|
||||
@@ -1410,15 +1414,16 @@ private fun PermissionsStep(
|
||||
}
|
||||
PermissionToggleRow(
|
||||
title = "Notification listener",
|
||||
subtitle = "notifications.list and notifications.actions (opens Android Settings)",
|
||||
subtitle = "Read and act on your notifications",
|
||||
checked = enableNotificationListener,
|
||||
granted = notificationListenerGranted,
|
||||
onCheckedChange = onNotificationListenerChange,
|
||||
)
|
||||
InlineDivider()
|
||||
|
||||
PermissionSectionHeader("Media")
|
||||
PermissionToggleRow(
|
||||
title = "Microphone",
|
||||
subtitle = "Foreground Voice tab transcription",
|
||||
subtitle = "Voice transcription in the Voice tab",
|
||||
checked = enableMicrophone,
|
||||
granted = isPermissionGranted(context, Manifest.permission.RECORD_AUDIO),
|
||||
onCheckedChange = onMicrophoneChange,
|
||||
@@ -1426,7 +1431,7 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Camera",
|
||||
subtitle = "camera.snap and camera.clip",
|
||||
subtitle = "Take photos and short video clips",
|
||||
checked = enableCamera,
|
||||
granted = isPermissionGranted(context, Manifest.permission.CAMERA),
|
||||
onCheckedChange = onCameraChange,
|
||||
@@ -1434,15 +1439,16 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Photos",
|
||||
subtitle = "photos.latest",
|
||||
subtitle = "Access your recent photos",
|
||||
checked = enablePhotos,
|
||||
granted = isPermissionGranted(context, photosPermission),
|
||||
onCheckedChange = onPhotosChange,
|
||||
)
|
||||
InlineDivider()
|
||||
|
||||
PermissionSectionHeader("Personal Data")
|
||||
PermissionToggleRow(
|
||||
title = "Contacts",
|
||||
subtitle = "contacts.search and contacts.add",
|
||||
subtitle = "Search and add contacts",
|
||||
checked = enableContacts,
|
||||
granted = contactsGranted,
|
||||
onCheckedChange = onContactsChange,
|
||||
@@ -1450,7 +1456,7 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Calendar",
|
||||
subtitle = "calendar.events and calendar.add",
|
||||
subtitle = "Read and create calendar events",
|
||||
checked = enableCalendar,
|
||||
granted = calendarGranted,
|
||||
onCheckedChange = onCalendarChange,
|
||||
@@ -1458,7 +1464,7 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "Motion",
|
||||
subtitle = "motion.activity and motion.pedometer",
|
||||
subtitle = "Activity and step tracking",
|
||||
checked = enableMotion,
|
||||
granted = motionGranted,
|
||||
onCheckedChange = onMotionChange,
|
||||
@@ -1469,16 +1475,25 @@ private fun PermissionsStep(
|
||||
InlineDivider()
|
||||
PermissionToggleRow(
|
||||
title = "SMS",
|
||||
subtitle = "Allow gateway-triggered SMS sending",
|
||||
subtitle = "Send text messages via the gateway",
|
||||
checked = enableSms,
|
||||
granted = isPermissionGranted(context, Manifest.permission.SEND_SMS),
|
||||
onCheckedChange = onSmsChange,
|
||||
)
|
||||
}
|
||||
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionSectionHeader(title: String) {
|
||||
Text(
|
||||
title.uppercase(),
|
||||
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
|
||||
color = onboardingAccent,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PermissionToggleRow(
|
||||
title: String,
|
||||
@@ -1489,6 +1504,12 @@ private fun PermissionToggleRow(
|
||||
statusOverride: String? = null,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val statusText = statusOverride ?: if (granted) "Granted" else "Not granted"
|
||||
val statusColor = when {
|
||||
statusOverride != null -> onboardingTextTertiary
|
||||
granted -> onboardingSuccess
|
||||
else -> onboardingWarning
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1497,11 +1518,7 @@ private fun PermissionToggleRow(
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text(subtitle, style = onboardingCalloutStyle.copy(lineHeight = 18.sp), color = onboardingTextSecondary)
|
||||
Text(
|
||||
statusOverride ?: if (granted) "Granted" else "Not granted",
|
||||
style = onboardingCaption1Style,
|
||||
color = if (granted) onboardingSuccess else onboardingTextSecondary,
|
||||
)
|
||||
Text(statusText, style = onboardingCaption1Style, color = statusColor)
|
||||
}
|
||||
Switch(
|
||||
checked = checked,
|
||||
@@ -1529,20 +1546,131 @@ private fun FinalStep(
|
||||
enabledPermissions: String,
|
||||
methodLabel: String,
|
||||
) {
|
||||
StepShell(title = "Review") {
|
||||
SummaryField(label = "Method", value = methodLabel)
|
||||
SummaryField(label = "Gateway", value = parsedGateway?.displayUrl ?: "Invalid gateway URL")
|
||||
SummaryField(label = "Enabled Permissions", value = enabledPermissions)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Review", style = onboardingTitle1Style, color = onboardingText)
|
||||
|
||||
SummaryCard(
|
||||
icon = Icons.Default.Link,
|
||||
label = "Method",
|
||||
value = methodLabel,
|
||||
accentColor = onboardingAccent,
|
||||
)
|
||||
SummaryCard(
|
||||
icon = Icons.Default.Cloud,
|
||||
label = "Gateway",
|
||||
value = parsedGateway?.displayUrl ?: "Invalid gateway URL",
|
||||
accentColor = Color(0xFF7C5AC7),
|
||||
)
|
||||
SummaryCard(
|
||||
icon = Icons.Default.Security,
|
||||
label = "Permissions",
|
||||
value = enabledPermissions,
|
||||
accentColor = onboardingSuccess,
|
||||
)
|
||||
|
||||
if (!attemptedConnect) {
|
||||
Text("Press Connect to verify gateway reachability and auth.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingAccentSoft,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingAccent.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.background(onboardingAccent.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Wifi,
|
||||
contentDescription = null,
|
||||
tint = onboardingAccent,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Tap Connect to verify your gateway is reachable.",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingAccent,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (isConnected) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color(0xFFEEF9F3),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.background(onboardingSuccess.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = onboardingSuccess,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Connected", style = onboardingHeadlineStyle, color = onboardingSuccess)
|
||||
Text(
|
||||
serverName ?: remoteAddress ?: "gateway",
|
||||
style = onboardingCalloutStyle,
|
||||
color = onboardingSuccess.copy(alpha = 0.8f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Status: $statusText", style = onboardingCalloutStyle, color = if (isConnected) onboardingSuccess else onboardingTextSecondary)
|
||||
if (isConnected) {
|
||||
Text("Connected to ${serverName ?: remoteAddress ?: "gateway"}", style = onboardingCalloutStyle, color = onboardingSuccess)
|
||||
} else {
|
||||
GuideBlock(title = "Pairing Required") {
|
||||
Text("Run these on the gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color(0xFFFFF8EC),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.background(onboardingWarning.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = onboardingWarning,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("Pairing Required", style = onboardingHeadlineStyle, color = onboardingWarning)
|
||||
Text("Run these on your gateway host:", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
CommandBlock("openclaw devices list")
|
||||
CommandBlock("openclaw devices approve <requestId>")
|
||||
Text("Then tap Connect again.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
@@ -1553,15 +1681,46 @@ private fun FinalStep(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryField(label: String, value: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
label,
|
||||
style = onboardingCaption2Style.copy(fontWeight = FontWeight.SemiBold, letterSpacing = 0.6.sp),
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text(value, style = onboardingHeadlineStyle, color = onboardingText)
|
||||
HorizontalDivider(color = onboardingBorder)
|
||||
private fun SummaryCard(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
value: String,
|
||||
accentColor: Color,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingSurface,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorder),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.background(accentColor.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
label.uppercase(),
|
||||
style = onboardingCaption1Style.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
|
||||
color = onboardingTextSecondary,
|
||||
)
|
||||
Text(value, style = onboardingHeadlineStyle, color = onboardingText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1571,10 +1730,12 @@ private fun CommandBlock(command: String) {
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(onboardingCommandBg, RoundedCornerShape(12.dp))
|
||||
.height(IntrinsicSize.Min)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(onboardingCommandBg)
|
||||
.border(width = 1.dp, color = onboardingCommandBorder, shape = RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(onboardingCommandAccent))
|
||||
Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(onboardingCommandAccent))
|
||||
Text(
|
||||
command,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
@@ -1586,23 +1747,42 @@ private fun CommandBlock(command: String) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Bullet(text: String) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.Top) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 7.dp)
|
||||
.size(8.dp)
|
||||
.background(onboardingAccentSoft, CircleShape),
|
||||
)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(top = 9.dp)
|
||||
.size(4.dp)
|
||||
.background(onboardingAccent, CircleShape),
|
||||
)
|
||||
Text(text, style = onboardingBodyStyle, color = onboardingTextSecondary, modifier = Modifier.weight(1f))
|
||||
private fun FeatureCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
accentColor: Color,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = onboardingSurface,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingBorder),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(14.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(42.dp)
|
||||
.background(accentColor.copy(alpha = 0.1f), RoundedCornerShape(11.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, style = onboardingHeadlineStyle, color = onboardingText)
|
||||
Text(subtitle, style = onboardingCalloutStyle, color = onboardingTextSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1610,6 +1790,10 @@ private fun isPermissionGranted(context: Context, permission: String): Boolean {
|
||||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun qrScannerErrorMessage(): String {
|
||||
return "Google Code Scanner could not start. Update Google Play services or use the setup code manually."
|
||||
}
|
||||
|
||||
private fun isNotificationListenerEnabled(context: Context): Boolean {
|
||||
return DeviceNotificationListenerService.isAccessEnabled(context)
|
||||
}
|
||||
|
||||
@@ -345,179 +345,90 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
"SETTINGS",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
Text("Device Configuration", style = mobileTitle2, color = mobileText)
|
||||
Text(
|
||||
"Manage capabilities, permissions, and diagnostics.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Order parity: Node → Voice → Camera → Messaging → Location → Screen.
|
||||
// ── Node ──
|
||||
item {
|
||||
Text(
|
||||
"NODE",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = viewModel::setDisplayName,
|
||||
label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
)
|
||||
}
|
||||
item { Text("Instance ID: $instanceId", style = mobileCallout.copy(fontFamily = FontFamily.Monospace), color = mobileTextSecondary) }
|
||||
item { Text("Device: $deviceModel", style = mobileCallout, color = mobileTextSecondary) }
|
||||
item { Text("Version: $appVersion", style = mobileCallout, color = mobileTextSecondary) }
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Voice
|
||||
item {
|
||||
Text(
|
||||
"VOICE",
|
||||
"DEVICE",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Microphone permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
OutlinedTextField(
|
||||
value = displayName,
|
||||
onValueChange = viewModel::setDisplayName,
|
||||
label = { Text("Name", style = mobileCaption1, color = mobileTextSecondary) },
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
textStyle = mobileBody.copy(color = mobileText),
|
||||
colors = settingsTextFieldColors(),
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text("$deviceModel · $appVersion", style = mobileCallout, color = mobileTextSecondary)
|
||||
Text(
|
||||
if (micPermissionGranted) {
|
||||
"Granted. Use the Voice tab mic button to capture transcript while the app is open."
|
||||
} else {
|
||||
"Required for foreground Voice tab transcription."
|
||||
},
|
||||
style = mobileCallout,
|
||||
instanceId.take(8) + "…",
|
||||
style = mobileCaption1.copy(fontFamily = FontFamily.Monospace),
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (micPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (micPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Voice wake and talk modes were removed. Voice now uses one mic on/off flow in the Voice tab while the app is open.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Camera
|
||||
item {
|
||||
Text(
|
||||
"CAMERA",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Allow Camera", style = mobileHeadline) },
|
||||
supportingContent = { Text("Allows the gateway to request photos or short video clips (foreground only).", style = mobileCallout) },
|
||||
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
"Tip: grant Microphone permission for video clips with audio.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Messaging
|
||||
item {
|
||||
Text(
|
||||
"MESSAGING",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
val buttonLabel =
|
||||
when {
|
||||
!smsPermissionAvailable -> "Unavailable"
|
||||
smsPermissionGranted -> "Manage"
|
||||
else -> "Grant"
|
||||
}
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("SMS Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (smsPermissionAvailable) {
|
||||
"Allow the gateway to send SMS from this device."
|
||||
} else {
|
||||
"SMS requires a device with telephony hardware."
|
||||
}
|
||||
|
||||
// ── Media ──
|
||||
item {
|
||||
Text(
|
||||
"MEDIA",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Microphone", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (micPermissionGranted) "Granted" else "Required for voice transcription.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (!smsPermissionAvailable) return@Button
|
||||
if (smsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (micPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (micPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = smsPermissionAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Camera", style = mobileHeadline) },
|
||||
supportingContent = { Text("Photos and video clips (foreground only).", style = mobileCallout) },
|
||||
trailingContent = { Switch(checked = cameraEnabled, onCheckedChange = ::setCameraEnabledChecked) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Notifications
|
||||
// ── Notifications & Messaging ──
|
||||
item {
|
||||
Text(
|
||||
"NOTIFICATIONS",
|
||||
@@ -526,67 +437,87 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
item {
|
||||
val buttonLabel =
|
||||
if (notificationsPermissionGranted) {
|
||||
"Manage"
|
||||
} else {
|
||||
"Grant"
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("System Notifications", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `system.notify` and Android foreground service alerts.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("System Notifications", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Alerts and foreground service.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (notificationsPermissionGranted || Build.VERSION.SDK_INT < 33) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
notificationsPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (notificationsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Notification Listener", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Read and interact with notifications.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = { openNotificationListenerSettings(context) },
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (notificationListenerEnabled) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (smsPermissionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("SMS", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text("Send SMS from this device.", style = mobileCallout)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (smsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
smsPermissionLauncher.launch(Manifest.permission.SEND_SMS)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (smsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(buttonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Notification Listener Access", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `notifications.list` and `notifications.actions`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = { openNotificationListenerSettings(context) },
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (notificationListenerEnabled) "Manage" else "Enable",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Data access
|
||||
// ── Data Access ──
|
||||
item {
|
||||
Text(
|
||||
"DATA ACCESS",
|
||||
@@ -595,142 +526,115 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `photos.latest`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Photos", style = mobileHeadline) },
|
||||
supportingContent = { Text("Access recent photos.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (photosPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
photosPermissionLauncher.launch(photosPermission)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Contacts", style = mobileHeadline) },
|
||||
supportingContent = { Text("Search and add contacts.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (contactsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (contactsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Calendar", style = mobileHeadline) },
|
||||
supportingContent = { Text("Read and create events.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (calendarPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (calendarPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (motionAvailable) {
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Motion", style = mobileHeadline) },
|
||||
supportingContent = { Text("Track steps and activity.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
val motionButtonLabel =
|
||||
when {
|
||||
!motionPermissionRequired -> "Manage"
|
||||
motionPermissionGranted -> "Manage"
|
||||
else -> "Grant"
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
if (!motionPermissionRequired || motionPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (photosPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Contacts Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `contacts.search` and `contacts.add`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (contactsPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
contactsPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (contactsPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Calendar Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
"Required for `calendar.events` and `calendar.add`.",
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (calendarPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
calendarPermissionLauncher.launch(arrayOf(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR))
|
||||
}
|
||||
},
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(
|
||||
if (calendarPermissionGranted) "Manage" else "Grant",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
val motionButtonLabel =
|
||||
when {
|
||||
!motionAvailable -> "Unavailable"
|
||||
!motionPermissionRequired -> "Manage"
|
||||
motionPermissionGranted -> "Manage"
|
||||
else -> "Grant"
|
||||
}
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Motion Permission", style = mobileHeadline) },
|
||||
supportingContent = {
|
||||
Text(
|
||||
if (!motionAvailable) {
|
||||
"This device does not expose accelerometer or step-counter motion sensors."
|
||||
} else {
|
||||
"Required for `motion.activity` and `motion.pedometer`."
|
||||
},
|
||||
style = mobileCallout,
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (!motionAvailable) return@Button
|
||||
if (!motionPermissionRequired || motionPermissionGranted) {
|
||||
openAppSettings(context)
|
||||
} else {
|
||||
motionPermissionLauncher.launch(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
},
|
||||
enabled = motionAvailable,
|
||||
colors = settingsPrimaryButtonColors(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
) {
|
||||
Text(motionButtonLabel, style = mobileCallout.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Location
|
||||
// ── Location ──
|
||||
item {
|
||||
Text(
|
||||
"LOCATION",
|
||||
@@ -739,7 +643,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
item {
|
||||
Column(modifier = Modifier.settingsRowModifier(), verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
@@ -781,50 +685,39 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
}
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Screen
|
||||
// ── Preferences ──
|
||||
item {
|
||||
Text(
|
||||
"SCREEN",
|
||||
"PREFERENCES",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Prevent Sleep", style = mobileHeadline) },
|
||||
supportingContent = { Text("Keeps the screen awake while OpenClaw is open.", style = mobileCallout) },
|
||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||
)
|
||||
}
|
||||
|
||||
item { HorizontalDivider(color = mobileBorder) }
|
||||
|
||||
// Debug
|
||||
item {
|
||||
Text(
|
||||
"DEBUG",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.sp),
|
||||
color = mobileAccent,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
modifier = Modifier.settingsRowModifier(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Debug Canvas Status", style = mobileHeadline) },
|
||||
supportingContent = { Text("Show status text in the canvas when debug is enabled.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = canvasDebugStatusEnabled,
|
||||
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
|
||||
Column(modifier = Modifier.settingsRowModifier()) {
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Prevent Sleep", style = mobileHeadline) },
|
||||
supportingContent = { Text("Keep screen awake while open.", style = mobileCallout) },
|
||||
trailingContent = { Switch(checked = preventSleep, onCheckedChange = viewModel::setPreventSleep) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
ListItem(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = listItemColors,
|
||||
headlineContent = { Text("Debug Canvas", style = mobileHeadline) },
|
||||
supportingContent = { Text("Show status overlay on canvas.", style = mobileCallout) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = canvasDebugStatusEnabled,
|
||||
onCheckedChange = viewModel::setCanvasDebugStatusEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(modifier = Modifier.height(24.dp)) }
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -212,19 +214,26 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Speaker toggle
|
||||
IconButton(
|
||||
onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
modifier = Modifier.size(48.dp),
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (speakerEnabled) mobileTextSecondary else mobileDanger,
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(
|
||||
onClick = { viewModel.setSpeakerEnabled(!speakerEnabled) },
|
||||
modifier = Modifier.size(48.dp),
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = if (speakerEnabled) mobileSurface else mobileDangerSoft,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (speakerEnabled) Icons.AutoMirrored.Filled.VolumeUp else Icons.AutoMirrored.Filled.VolumeOff,
|
||||
contentDescription = if (speakerEnabled) "Mute speaker" else "Unmute speaker",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (speakerEnabled) mobileTextSecondary else mobileDanger,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (speakerEnabled) "Speaker" else "Muted",
|
||||
style = mobileCaption2,
|
||||
color = if (speakerEnabled) mobileTextTertiary else mobileDanger,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -278,8 +287,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// Invisible spacer to balance the row (same size as speaker button)
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
// Invisible spacer to balance the row (matches speaker column width)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("", style = mobileCaption2)
|
||||
}
|
||||
}
|
||||
|
||||
// Status + labels
|
||||
@@ -292,11 +305,24 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
micEnabled -> "Listening"
|
||||
else -> "Mic off"
|
||||
}
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
style = mobileCaption1,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
val stateColor =
|
||||
when {
|
||||
micEnabled -> mobileSuccess
|
||||
micIsSending -> mobileAccent
|
||||
else -> mobileTextSecondary
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (micEnabled) mobileSuccessSoft else mobileSurface,
|
||||
border = BorderStroke(1.dp, if (micEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder),
|
||||
) {
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = stateColor,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasMicPermission) {
|
||||
val showRationale =
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -78,65 +77,15 @@ fun ChatComposer(
|
||||
val sendBusy = pendingRunCount > 0
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = mobileAccentSoft,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Thinking: ${thinkingLabel(thinkingLevel)}",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileText,
|
||||
)
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", tint = mobileTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
}
|
||||
}
|
||||
|
||||
SecondaryActionButton(
|
||||
label = "Attach",
|
||||
icon = Icons.Default.AttachFile,
|
||||
enabled = true,
|
||||
onClick = onPickImages,
|
||||
)
|
||||
}
|
||||
|
||||
if (attachments.isNotEmpty()) {
|
||||
AttachmentsStrip(attachments = attachments, onRemoveAttachment = onRemoveAttachment)
|
||||
}
|
||||
|
||||
HorizontalDivider(color = mobileBorder)
|
||||
|
||||
Text(
|
||||
text = "MESSAGE",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.9.sp),
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.fillMaxWidth().height(92.dp),
|
||||
placeholder = { Text("Type a message", style = mobileBodyStyle(), color = mobileTextTertiary) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text("Type a message…", style = mobileBodyStyle(), color = mobileTextTertiary) },
|
||||
minLines = 2,
|
||||
maxLines = 5,
|
||||
textStyle = mobileBodyStyle().copy(color = mobileText),
|
||||
@@ -155,26 +104,62 @@ fun ChatComposer(
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SecondaryActionButton(
|
||||
label = "Refresh",
|
||||
icon = Icons.Default.Refresh,
|
||||
enabled = true,
|
||||
compact = true,
|
||||
onClick = onRefresh,
|
||||
)
|
||||
Box {
|
||||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = thinkingLabel(thinkingLevel),
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Icon(Icons.Default.ArrowDropDown, contentDescription = "Select thinking level", modifier = Modifier.size(18.dp), tint = mobileTextTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
SecondaryActionButton(
|
||||
label = "Abort",
|
||||
icon = Icons.Default.Stop,
|
||||
enabled = pendingRunCount > 0,
|
||||
compact = true,
|
||||
onClick = onAbort,
|
||||
)
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
ThinkingMenuItem("high", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
|
||||
}
|
||||
}
|
||||
|
||||
SecondaryActionButton(
|
||||
label = "Attach",
|
||||
icon = Icons.Default.AttachFile,
|
||||
enabled = true,
|
||||
compact = true,
|
||||
onClick = onPickImages,
|
||||
)
|
||||
|
||||
SecondaryActionButton(
|
||||
label = "Refresh",
|
||||
icon = Icons.Default.Refresh,
|
||||
enabled = true,
|
||||
compact = true,
|
||||
onClick = onRefresh,
|
||||
)
|
||||
|
||||
SecondaryActionButton(
|
||||
label = "Abort",
|
||||
icon = Icons.Default.Stop,
|
||||
enabled = pendingRunCount > 0,
|
||||
compact = true,
|
||||
onClick = onAbort,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val text = input
|
||||
@@ -182,8 +167,9 @@ fun ChatComposer(
|
||||
onSend(text)
|
||||
},
|
||||
enabled = canSend,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
modifier = Modifier.height(44.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = mobileAccent,
|
||||
@@ -198,7 +184,7 @@ fun ChatComposer(
|
||||
} else {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Send",
|
||||
style = mobileHeadline.copy(fontWeight = FontWeight.Bold),
|
||||
|
||||
@@ -151,7 +151,7 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
|
||||
ChatBubbleContainer(
|
||||
style = bubbleStyle("assistant"),
|
||||
roleLabel = "TOOLS",
|
||||
roleLabel = "Tools",
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Running tools...", style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary)
|
||||
@@ -188,7 +188,7 @@ fun ChatPendingToolsBubble(toolCalls: List<ChatPendingToolCall>) {
|
||||
fun ChatStreamingAssistantBubble(text: String) {
|
||||
ChatBubbleContainer(
|
||||
style = bubbleStyle("assistant").copy(borderColor = mobileAccent),
|
||||
roleLabel = "ASSISTANT · LIVE",
|
||||
roleLabel = "OpenClaw · Live",
|
||||
) {
|
||||
ChatMarkdown(text = text, textColor = mobileText)
|
||||
}
|
||||
@@ -224,9 +224,9 @@ private fun bubbleStyle(role: String): ChatBubbleStyle {
|
||||
|
||||
private fun roleLabel(role: String): String {
|
||||
return when (role) {
|
||||
"user" -> "USER"
|
||||
"system" -> "SYSTEM"
|
||||
else -> "ASSISTANT"
|
||||
"user" -> "You"
|
||||
"system" -> "System"
|
||||
else -> "OpenClaw"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,8 @@ import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCaption2
|
||||
import ai.openclaw.app.ui.mobileDanger
|
||||
import ai.openclaw.app.ui.mobileSuccess
|
||||
import ai.openclaw.app.ui.mobileSuccessSoft
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
import ai.openclaw.app.ui.mobileWarning
|
||||
import ai.openclaw.app.ui.mobileWarningSoft
|
||||
import java.io.ByteArrayOutputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -106,7 +102,6 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
healthOk = healthOk,
|
||||
onSelectSession = { key -> viewModel.switchChatSession(key) },
|
||||
)
|
||||
|
||||
@@ -160,77 +155,34 @@ private fun ChatThreadSelector(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
healthOk: Boolean,
|
||||
onSelectSession: (String) -> Unit,
|
||||
) {
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
val currentSessionLabel =
|
||||
friendlySessionName(sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey)
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "SESSION",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.Bold, letterSpacing = 0.8.sp),
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (entry in sessionOptions) {
|
||||
val active = entry.key == sessionKey
|
||||
Surface(
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (active) mobileAccent else Color.White,
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Text(
|
||||
text = currentSessionLabel,
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = mobileText,
|
||||
text = friendlySessionName(entry.displayName ?: entry.key),
|
||||
style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold),
|
||||
color = if (active) Color.White else mobileText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
ChatConnectionPill(healthOk = healthOk)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (entry in sessionOptions) {
|
||||
val active = entry.key == sessionKey
|
||||
Surface(
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (active) mobileAccent else Color.White,
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Text(
|
||||
text = friendlySessionName(entry.displayName ?: entry.key),
|
||||
style = mobileCaption1.copy(fontWeight = if (active) FontWeight.Bold else FontWeight.SemiBold),
|
||||
color = if (active) Color.White else mobileText,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatConnectionPill(healthOk: Boolean) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (healthOk) mobileSuccessSoft else mobileWarningSoft,
|
||||
border = BorderStroke(1.dp, if (healthOk) mobileSuccess.copy(alpha = 0.35f) else mobileWarning.copy(alpha = 0.35f)),
|
||||
) {
|
||||
Text(
|
||||
text = if (healthOk) "Connected" else "Offline",
|
||||
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = if (healthOk) mobileSuccess else mobileWarning,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,26 +79,30 @@ internal object TalkModeVoiceResolver {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
|
||||
val code = conn.responseCode
|
||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
||||
val data = stream.readBytes()
|
||||
if (code >= 400) {
|
||||
val message = data.toString(Charsets.UTF_8)
|
||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
||||
}
|
||||
val code = conn.responseCode
|
||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
||||
val data = stream?.use { it.readBytes() } ?: byteArrayOf()
|
||||
if (code >= 400) {
|
||||
val message = data.toString(Charsets.UTF_8)
|
||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
||||
}
|
||||
|
||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
||||
voices.mapNotNull { entry ->
|
||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()
|
||||
ElevenLabsVoice(voiceId, name)
|
||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
||||
voices.mapNotNull { entry ->
|
||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()
|
||||
ElevenLabsVoice(voiceId, name)
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,9 @@ Release behavior:
|
||||
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||
- Root `package.json.version` is the only version source for iOS.
|
||||
- A root version like `2026.3.12-beta.1` becomes:
|
||||
- `CFBundleShortVersionString = 2026.3.12`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.3.12`
|
||||
- A root version like `2026.3.13-beta.1` becomes:
|
||||
- `CFBundleShortVersionString = 2026.3.13`
|
||||
- `CFBundleVersion = next TestFlight build number for 2026.3.13`
|
||||
|
||||
Required env for beta builds:
|
||||
|
||||
|
||||
@@ -189,6 +189,7 @@ final class ShareViewController: UIViewController {
|
||||
try await gateway.connect(
|
||||
url: url,
|
||||
token: config.token,
|
||||
bootstrapToken: nil,
|
||||
password: config.password,
|
||||
connectOptions: makeOptions("openclaw-ios"),
|
||||
sessionBox: nil,
|
||||
@@ -208,6 +209,7 @@ final class ShareViewController: UIViewController {
|
||||
try await gateway.connect(
|
||||
url: url,
|
||||
token: config.token,
|
||||
bootstrapToken: nil,
|
||||
password: config.password,
|
||||
connectOptions: makeOptions("moltbot-ios"),
|
||||
sessionBox: nil,
|
||||
|
||||
@@ -19,6 +19,7 @@ enum OnboardingConnectionMode: String, CaseIterable {
|
||||
|
||||
enum OnboardingStateStore {
|
||||
private static let completedDefaultsKey = "onboarding.completed"
|
||||
private static let firstRunIntroSeenDefaultsKey = "onboarding.first_run_intro_seen"
|
||||
private static let lastModeDefaultsKey = "onboarding.last_mode"
|
||||
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
|
||||
|
||||
@@ -39,10 +40,23 @@ enum OnboardingStateStore {
|
||||
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
||||
}
|
||||
|
||||
static func shouldPresentFirstRunIntro(defaults: UserDefaults = .standard) -> Bool {
|
||||
!defaults.bool(forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markFirstRunIntroSeen(defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func reset(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
defaults.set(false, forKey: Self.firstRunIntroSeenDefaultsKey)
|
||||
}
|
||||
|
||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
@@ -6,6 +6,7 @@ import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum OnboardingStep: Int, CaseIterable {
|
||||
case intro
|
||||
case welcome
|
||||
case mode
|
||||
case connect
|
||||
@@ -29,7 +30,8 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .welcome: "Welcome"
|
||||
case .intro: "Welcome"
|
||||
case .welcome: "Connect Gateway"
|
||||
case .mode: "Connection Mode"
|
||||
case .connect: "Connect"
|
||||
case .auth: "Authentication"
|
||||
@@ -38,7 +40,7 @@ private enum OnboardingStep: Int, CaseIterable {
|
||||
}
|
||||
|
||||
var canGoBack: Bool {
|
||||
self != .welcome && self != .success
|
||||
self != .intro && self != .welcome && self != .success
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +51,7 @@ struct OnboardingWizardView: View {
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
|
||||
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
|
||||
@State private var step: OnboardingStep = .welcome
|
||||
@State private var step: OnboardingStep
|
||||
@State private var selectedMode: OnboardingConnectionMode?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPort: Int = 18789
|
||||
@@ -58,11 +60,10 @@ struct OnboardingWizardView: View {
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var connectMessage: String?
|
||||
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
|
||||
@State private var statusLine: String = "In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var issue: GatewayConnectionIssue = .none
|
||||
@State private var didMarkCompleted = false
|
||||
@State private var didAutoPresentQR = false
|
||||
@State private var pairingRequestId: String?
|
||||
@State private var discoveryRestartTask: Task<Void, Never>?
|
||||
@State private var showQRScanner: Bool = false
|
||||
@@ -74,14 +75,23 @@ struct OnboardingWizardView: View {
|
||||
let allowSkip: Bool
|
||||
let onClose: () -> Void
|
||||
|
||||
init(allowSkip: Bool, onClose: @escaping () -> Void) {
|
||||
self.allowSkip = allowSkip
|
||||
self.onClose = onClose
|
||||
_step = State(
|
||||
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
|
||||
}
|
||||
|
||||
private var isFullScreenStep: Bool {
|
||||
self.step == .welcome || self.step == .success
|
||||
self.step == .intro || self.step == .welcome || self.step == .success
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch self.step {
|
||||
case .intro:
|
||||
self.introStep
|
||||
case .welcome:
|
||||
self.welcomeStep
|
||||
case .success:
|
||||
@@ -293,6 +303,83 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var introStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "iphone.gen3")
|
||||
.font(.system(size: 60, weight: .semibold))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.bottom, 10)
|
||||
|
||||
Text("Turn this iPhone into a secure OpenClaw node for chat, voice, camera, and device tools.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Label("Connect to your gateway", systemImage: "link")
|
||||
Label("Choose device permissions", systemImage: "hand.raised")
|
||||
Label("Use OpenClaw from your phone", systemImage: "message.fill")
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 24)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Security notice")
|
||||
.font(.headline)
|
||||
Text(
|
||||
"The connected OpenClaw agent can use device capabilities you enable, such as camera, microphone, photos, contacts, calendar, and location. Continue only if you trust the gateway and agent you connect to.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(18)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
self.advanceFromIntro()
|
||||
} label: {
|
||||
Text("Continue")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -303,16 +390,37 @@ struct OnboardingWizardView: View {
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Welcome")
|
||||
Text("Connect Gateway")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Connect to your OpenClaw gateway")
|
||||
Text("Scan a QR code from your OpenClaw gateway or continue with manual setup.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("How to pair")
|
||||
.font(.headline)
|
||||
Text("In your OpenClaw chat, run")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("/pair qr")
|
||||
.font(.system(.footnote, design: .monospaced).weight(.semibold))
|
||||
Text("Then scan the QR code here to connect this iPhone.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
@@ -342,8 +450,7 @@ struct OnboardingWizardView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,6 +834,12 @@ struct OnboardingWizardView: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private func advanceFromIntro() {
|
||||
OnboardingStateStore.markFirstRunIntroSeen()
|
||||
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
self.step = .welcome
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
guard let target = self.step.previous else { return }
|
||||
self.connectingGatewayID = nil
|
||||
@@ -775,10 +888,8 @@ struct OnboardingWizardView: View {
|
||||
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
|
||||
self.didAutoPresentQR = true
|
||||
self.statusLine = "No saved pairing found. Scan QR code to connect."
|
||||
self.showQRScanner = true
|
||||
if !hasSavedGateway, !hasToken, !hasPassword {
|
||||
self.statusLine = "No saved pairing found. In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1008,6 +1008,7 @@ struct SettingsTab: View {
|
||||
|
||||
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
OnboardingStateStore.reset()
|
||||
|
||||
// RootCanvas also short-circuits onboarding when these are true.
|
||||
self.onboardingComplete = false
|
||||
|
||||
@@ -39,6 +39,35 @@ import Testing
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
@Test func firstRunIntroDefaultsToVisibleThenPersists() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
|
||||
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
|
||||
#expect(!OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func resetClearsCompletionAndIntroSeen() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
OnboardingStateStore.markCompleted(mode: .homeNetwork, defaults: defaults)
|
||||
OnboardingStateStore.markFirstRunIntroSeen(defaults: defaults)
|
||||
|
||||
OnboardingStateStore.reset(defaults: defaults)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
#expect(OnboardingStateStore.shouldPresentFirstRunIntro(defaults: defaults))
|
||||
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .homeNetwork)
|
||||
}
|
||||
|
||||
private struct TestDefaults {
|
||||
var suiteName: String
|
||||
var defaults: UserDefaults
|
||||
|
||||
@@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
|
||||
version = raw_value.to_s.strip.sub(/\Av/, "")
|
||||
UI.user_error!("Missing root package.json version.") unless env_present?(version)
|
||||
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
|
||||
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.12 or 2026.3.12-beta.1.")
|
||||
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.13 or 2026.3.13-beta.1.")
|
||||
end
|
||||
|
||||
version
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.12</string>
|
||||
<string>2026.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202603120</string>
|
||||
<string>202603130</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
1
changelog/fragments/openai-codex-auth-tests-gpt54.md
Normal file
1
changelog/fragments/openai-codex-auth-tests-gpt54.md
Normal file
@@ -0,0 +1 @@
|
||||
- tests: align OpenAI Codex auth login expectations with the `gpt-5.4` default model to prevent stale CI failures. (#44367) thanks @jrrcdev
|
||||
@@ -9,6 +9,7 @@ services:
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
||||
TZ: ${OPENCLAW_TZ:-UTC}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
|
||||
@@ -65,6 +66,7 @@ services:
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
||||
TZ: ${OPENCLAW_TZ:-UTC}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
|
||||
|
||||
@@ -10,6 +10,7 @@ HOME_VOLUME_NAME="${OPENCLAW_HOME_VOLUME:-}"
|
||||
RAW_SANDBOX_SETTING="${OPENCLAW_SANDBOX:-}"
|
||||
SANDBOX_ENABLED=""
|
||||
DOCKER_SOCKET_PATH="${OPENCLAW_DOCKER_SOCKET:-}"
|
||||
TIMEZONE="${OPENCLAW_TZ:-}"
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
@@ -135,6 +136,11 @@ contains_disallowed_chars() {
|
||||
[[ "$value" == *$'\n'* || "$value" == *$'\r'* || "$value" == *$'\t'* ]]
|
||||
}
|
||||
|
||||
is_valid_timezone() {
|
||||
local value="$1"
|
||||
[[ -e "/usr/share/zoneinfo/$value" && ! -d "/usr/share/zoneinfo/$value" ]]
|
||||
}
|
||||
|
||||
validate_mount_path_value() {
|
||||
local label="$1"
|
||||
local value="$2"
|
||||
@@ -202,6 +208,17 @@ fi
|
||||
if [[ -n "$SANDBOX_ENABLED" ]]; then
|
||||
validate_mount_path_value "OPENCLAW_DOCKER_SOCKET" "$DOCKER_SOCKET_PATH"
|
||||
fi
|
||||
if [[ -n "$TIMEZONE" ]]; then
|
||||
if contains_disallowed_chars "$TIMEZONE"; then
|
||||
fail "OPENCLAW_TZ contains unsupported control characters."
|
||||
fi
|
||||
if [[ ! "$TIMEZONE" =~ ^[A-Za-z0-9/_+\-]+$ ]]; then
|
||||
fail "OPENCLAW_TZ must be a valid IANA timezone string (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
if ! is_valid_timezone "$TIMEZONE"; then
|
||||
fail "OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo (e.g. Asia/Shanghai)."
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$OPENCLAW_CONFIG_DIR"
|
||||
mkdir -p "$OPENCLAW_WORKSPACE_DIR"
|
||||
@@ -224,6 +241,7 @@ 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"
|
||||
export OPENCLAW_TZ="$TIMEZONE"
|
||||
|
||||
# Detect Docker socket GID for sandbox group_add.
|
||||
DOCKER_GID=""
|
||||
@@ -408,7 +426,8 @@ upsert_env "$ENV_FILE" \
|
||||
OPENCLAW_DOCKER_SOCKET \
|
||||
DOCKER_GID \
|
||||
OPENCLAW_INSTALL_DOCKER_CLI \
|
||||
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS
|
||||
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS \
|
||||
OPENCLAW_TZ
|
||||
|
||||
if [[ "$IMAGE_NAME" == "openclaw:local" ]]; then
|
||||
echo "==> Building Docker image: $IMAGE_NAME"
|
||||
|
||||
@@ -145,7 +145,7 @@ Configure your tunnel's ingress rules to only route the webhook path:
|
||||
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
|
||||
- `audienceType: "project-number"` → audience is the Cloud project number.
|
||||
3. Messages are routed by space:
|
||||
- DMs use session key `agent:<agentId>:googlechat:dm:<spaceId>`.
|
||||
- DMs use session key `agent:<agentId>:googlechat:direct:<spaceId>`.
|
||||
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
|
||||
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
|
||||
- `openclaw pairing approve googlechat <code>`
|
||||
|
||||
@@ -195,6 +195,8 @@ Groups:
|
||||
|
||||
- `channels.signal.groupPolicy = open | allowlist | disabled`.
|
||||
- `channels.signal.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
|
||||
- `channels.signal.groups["<group-id>" | "*"]` can override group behavior with `requireMention`, `tools`, and `toolsBySender`.
|
||||
- Use `channels.signal.accounts.<id>.groups` for per-account overrides in multi-account setups.
|
||||
- Runtime note: if `channels.signal` is completely missing, runtime falls back to `groupPolicy="allowlist"` for group checks (even if `channels.defaults.groupPolicy` is set).
|
||||
|
||||
## How it works (behavior)
|
||||
@@ -312,6 +314,8 @@ Provider options:
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.groups`: per-group overrides keyed by Signal group id (or `"*"`). Supported fields: `requireMention`, `tools`, `toolsBySender`.
|
||||
- `channels.signal.accounts.<id>.groups`: per-account version of `channels.signal.groups` for multi-account setups.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
|
||||
- `channels.signal.textChunkLimit`: outbound chunk size (chars).
|
||||
|
||||
@@ -60,7 +60,7 @@ openclaw sessions cleanup --dry-run
|
||||
openclaw sessions cleanup --agent work --dry-run
|
||||
openclaw sessions cleanup --all-agents --dry-run
|
||||
openclaw sessions cleanup --enforce
|
||||
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:dm:123"
|
||||
openclaw sessions cleanup --enforce --active-key "agent:main:telegram:direct:123"
|
||||
openclaw sessions cleanup --json
|
||||
```
|
||||
|
||||
|
||||
@@ -191,9 +191,9 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Direct chats follow `session.dmScope` (default `main`).
|
||||
- `main`: `agent:<agentId>:<mainKey>` (continuity across devices/channels).
|
||||
- Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
|
||||
- `per-peer`: `agent:<agentId>:dm:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
|
||||
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
|
||||
- `per-peer`: `agent:<agentId>:direct:<peerId>`.
|
||||
- `per-channel-peer`: `agent:<agentId>:<channel>:direct:<peerId>`.
|
||||
- `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:direct:<peerId>` (accountId defaults to `default`).
|
||||
- If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
|
||||
- Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
|
||||
- Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
|
||||
|
||||
@@ -59,7 +59,7 @@ Bootstrap files are trimmed and appended under **Project Context** so the model
|
||||
- `USER.md`
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (only on brand-new workspaces)
|
||||
- `MEMORY.md` and/or `memory.md` (when present in the workspace; either or both may be injected)
|
||||
- `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback
|
||||
|
||||
All of these files are **injected into the context window** on every turn, which
|
||||
means they consume tokens. Keep them concise — especially `MEMORY.md`, which can
|
||||
|
||||
@@ -472,7 +472,7 @@ Control-plane write RPCs (`config.apply`, `config.patch`, `update.run`) are rate
|
||||
openclaw gateway call config.apply --params '{
|
||||
"raw": "{ agents: { defaults: { workspace: \"~/.openclaw/workspace\" } } }",
|
||||
"baseHash": "<hash>",
|
||||
"sessionKey": "agent:main:whatsapp:dm:+15555550123"
|
||||
"sessionKey": "agent:main:whatsapp:direct:+15555550123"
|
||||
}'
|
||||
```
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Notes:
|
||||
# Default is auto-derived from APP_VERSION when omitted.
|
||||
SKIP_NOTARIZE=1 \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.12 \
|
||||
APP_VERSION=2026.3.13 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
@@ -47,10 +47,10 @@ scripts/package-mac-dist.sh
|
||||
# `package-mac-dist.sh` already creates the zip + DMG.
|
||||
# If you used `package-mac-app.sh` directly instead, create them manually:
|
||||
# If you want notarization/stapling in this step, use the NOTARIZE command below.
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.12.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip
|
||||
|
||||
# Optional: build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.12.dmg
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -58,13 +58,13 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.12.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.12 \
|
||||
APP_VERSION=2026.3.13 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
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.12.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
@@ -72,7 +72,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.12.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.13.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.
|
||||
@@ -80,7 +80,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.3.12.zip` (and `OpenClaw-2026.3.12.dSYM.zip`) to the GitHub release for tag `v2026.3.12`.
|
||||
- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`.
|
||||
- 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.
|
||||
|
||||
@@ -41,6 +41,7 @@ Current caveats:
|
||||
- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health`
|
||||
- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first
|
||||
- if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately
|
||||
- if `schtasks` itself wedges or stops responding, OpenClaw now aborts that path quickly and falls back instead of hanging forever
|
||||
- Scheduled Tasks are still preferred when available because they provide better supervisor status
|
||||
|
||||
If you want the native CLI only, without gateway service install, use one of these:
|
||||
|
||||
@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
|
||||
- Tool list + short descriptions
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`)
|
||||
- Self-update instructions
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present or `memory.md` as a lowercase fallback). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
|
||||
@@ -271,6 +271,8 @@ Approval-backed interpreter/runtime runs are intentionally conservative:
|
||||
- Exact argv/cwd/env context is always bound.
|
||||
- Direct shell script and direct runtime file forms are best-effort bound to one concrete local
|
||||
file snapshot.
|
||||
- Common package-manager wrapper forms that still resolve to one direct local file (for example
|
||||
`pnpm exec`, `pnpm node`, `npm exec`, `npx`) are unwrapped before binding.
|
||||
- If OpenClaw cannot identify exactly one concrete local file for an interpreter/runtime command
|
||||
(for example package scripts, eval forms, runtime-specific loader chains, or ambiguous multi-file
|
||||
forms), approval-backed execution is denied instead of claiming semantic coverage it does not
|
||||
|
||||
@@ -85,6 +85,13 @@ Implications:
|
||||
Use allowlists and explicit install/load paths for non-bundled plugins. Treat
|
||||
workspace plugins as development-time code, not production defaults.
|
||||
|
||||
Important trust note:
|
||||
|
||||
- `plugins.allow` trusts **plugin ids**, not source provenance.
|
||||
- A workspace plugin with the same id as a bundled plugin intentionally shadows
|
||||
the bundled copy when that workspace plugin is enabled/allowlisted.
|
||||
- This is normal and useful for local development, patch testing, and hotfixes.
|
||||
|
||||
## Available plugins (official)
|
||||
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@openclaw/msteams` if you use Teams.
|
||||
@@ -363,6 +370,14 @@ manifest.
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
|
||||
That means:
|
||||
|
||||
- workspace plugins intentionally shadow bundled plugins with the same id
|
||||
- `plugins.allow: ["foo"]` authorizes the active `foo` plugin by id, even when
|
||||
the active copy comes from the workspace instead of the bundled extension root
|
||||
- if you need stricter provenance control, use explicit install/load paths and
|
||||
inspect the resolved plugin source before enabling it
|
||||
|
||||
### Enablement rules
|
||||
|
||||
Enablement is resolved after discovery:
|
||||
@@ -372,6 +387,7 @@ Enablement is resolved after discovery:
|
||||
- `plugins.entries.<id>.enabled: false` disables that plugin
|
||||
- workspace-origin plugins are disabled by default
|
||||
- allowlists restrict the active set when `plugins.allow` is non-empty
|
||||
- allowlists are **id-based**, not source-based
|
||||
- bundled plugins are disabled by default unless:
|
||||
- the bundled id is in the built-in default-on set, or
|
||||
- you explicitly enable it, or
|
||||
@@ -1322,6 +1338,8 @@ Plugins run in-process with the Gateway. Treat them as trusted code:
|
||||
|
||||
- Only install plugins you trust.
|
||||
- Prefer `plugins.allow` allowlists.
|
||||
- Remember that `plugins.allow` is id-based, so an enabled workspace plugin can
|
||||
intentionally shadow a bundled plugin with the same id.
|
||||
- Restart the Gateway after changes.
|
||||
|
||||
## Testing plugins
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/acpx",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw ACP runtime backend via acpx",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -162,6 +162,39 @@ function resolveTextChunk(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createTextDeltaEvent(params: {
|
||||
content: string | null | undefined;
|
||||
stream: "output" | "thought";
|
||||
tag?: AcpSessionUpdateTag;
|
||||
}): AcpRuntimeEvent | null {
|
||||
if (params.content == null || params.content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: params.content,
|
||||
stream: params.stream,
|
||||
...(params.tag ? { tag: params.tag } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createToolCallEvent(params: {
|
||||
payload: Record<string, unknown>;
|
||||
tag: AcpSessionUpdateTag;
|
||||
}): AcpRuntimeEvent {
|
||||
const title = asTrimmedString(params.payload.title) || "tool call";
|
||||
const status = asTrimmedString(params.payload.status);
|
||||
const toolCallId = asOptionalString(params.payload.toolCallId);
|
||||
return {
|
||||
type: "tool_call",
|
||||
text: status ? `${title} (${status})` : title,
|
||||
tag: params.tag,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
@@ -187,57 +220,28 @@ export function parsePromptEventLine(line: string): AcpRuntimeEvent | null {
|
||||
const tag = structured.tag;
|
||||
|
||||
switch (type) {
|
||||
case "text": {
|
||||
const content = asString(payload.content);
|
||||
if (content == null || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: content,
|
||||
case "text":
|
||||
return createTextDeltaEvent({
|
||||
content: asString(payload.content),
|
||||
stream: "output",
|
||||
...(tag ? { tag } : {}),
|
||||
};
|
||||
}
|
||||
case "thought": {
|
||||
const content = asString(payload.content);
|
||||
if (content == null || content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
type: "text_delta",
|
||||
text: content,
|
||||
tag,
|
||||
});
|
||||
case "thought":
|
||||
return createTextDeltaEvent({
|
||||
content: asString(payload.content),
|
||||
stream: "thought",
|
||||
...(tag ? { tag } : {}),
|
||||
};
|
||||
}
|
||||
case "tool_call": {
|
||||
const title = asTrimmedString(payload.title) || "tool call";
|
||||
const status = asTrimmedString(payload.status);
|
||||
const toolCallId = asOptionalString(payload.toolCallId);
|
||||
return {
|
||||
type: "tool_call",
|
||||
text: status ? `${title} (${status})` : title,
|
||||
tag,
|
||||
});
|
||||
case "tool_call":
|
||||
return createToolCallEvent({
|
||||
payload,
|
||||
tag: (tag ?? "tool_call") as AcpSessionUpdateTag,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
title,
|
||||
};
|
||||
}
|
||||
case "tool_call_update": {
|
||||
const title = asTrimmedString(payload.title) || "tool call";
|
||||
const status = asTrimmedString(payload.status);
|
||||
const toolCallId = asOptionalString(payload.toolCallId);
|
||||
const text = status ? `${title} (${status})` : title;
|
||||
return {
|
||||
type: "tool_call",
|
||||
text,
|
||||
});
|
||||
case "tool_call_update":
|
||||
return createToolCallEvent({
|
||||
payload,
|
||||
tag: (tag ?? "tool_call_update") as AcpSessionUpdateTag,
|
||||
...(toolCallId ? { toolCallId } : {}),
|
||||
...(status ? { status } : {}),
|
||||
title,
|
||||
};
|
||||
}
|
||||
});
|
||||
case "agent_message_chunk":
|
||||
return resolveTextChunk({
|
||||
payload,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from "openclaw/plugin-sdk/acpx";
|
||||
import { AcpRuntimeError } from "openclaw/plugin-sdk/acpx";
|
||||
import { toAcpMcpServers, type ResolvedAcpxPluginConfig } from "./config.js";
|
||||
import { checkAcpxVersion } from "./ensure.js";
|
||||
import { checkAcpxVersion, type AcpxVersionCheckResult } from "./ensure.js";
|
||||
import {
|
||||
parseJsonLines,
|
||||
parsePromptEventLine,
|
||||
@@ -51,6 +51,28 @@ const ACPX_CAPABILITIES: AcpRuntimeCapabilities = {
|
||||
controls: ["session/set_mode", "session/set_config_option", "session/status"],
|
||||
};
|
||||
|
||||
type AcpxHealthCheckResult =
|
||||
| {
|
||||
ok: true;
|
||||
versionCheck: Extract<AcpxVersionCheckResult, { ok: true }>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
failure:
|
||||
| {
|
||||
kind: "version-check";
|
||||
versionCheck: Extract<AcpxVersionCheckResult, { ok: false }>;
|
||||
}
|
||||
| {
|
||||
kind: "help-check";
|
||||
result: Awaited<ReturnType<typeof spawnAndCollect>>;
|
||||
}
|
||||
| {
|
||||
kind: "exception";
|
||||
error: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function formatPermissionModeGuidance(): string {
|
||||
return "Configure plugins.entries.acpx.config.permissionMode to one of: approve-reads, approve-all, deny-all.";
|
||||
}
|
||||
@@ -165,35 +187,71 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
async probeAvailability(): Promise<void> {
|
||||
const versionCheck = await checkAcpxVersion({
|
||||
private async checkVersion(): Promise<AcpxVersionCheckResult> {
|
||||
return await checkAcpxVersion({
|
||||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: this.config.expectedVersion,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
spawnOptions: this.spawnCommandOptions,
|
||||
});
|
||||
}
|
||||
|
||||
private async runHelpCheck(): Promise<Awaited<ReturnType<typeof spawnAndCollect>>> {
|
||||
return await spawnAndCollect(
|
||||
{
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
);
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<AcpxHealthCheckResult> {
|
||||
const versionCheck = await this.checkVersion();
|
||||
if (!versionCheck.ok) {
|
||||
this.healthy = false;
|
||||
return;
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
kind: "version-check",
|
||||
versionCheck,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAndCollect(
|
||||
{
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
const result = await this.runHelpCheck();
|
||||
if (result.error != null || (result.code ?? 0) !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
kind: "help-check",
|
||||
result,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
versionCheck,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
kind: "exception",
|
||||
error,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
);
|
||||
this.healthy = result.error == null && (result.code ?? 0) === 0;
|
||||
} catch {
|
||||
this.healthy = false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async probeAvailability(): Promise<void> {
|
||||
const result = await this.checkHealth();
|
||||
this.healthy = result.ok;
|
||||
}
|
||||
|
||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||
const sessionName = asTrimmedString(input.sessionKey);
|
||||
if (!sessionName) {
|
||||
@@ -494,14 +552,9 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
}
|
||||
|
||||
async doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
const versionCheck = await checkAcpxVersion({
|
||||
command: this.config.command,
|
||||
cwd: this.config.cwd,
|
||||
expectedVersion: this.config.expectedVersion,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
spawnOptions: this.spawnCommandOptions,
|
||||
});
|
||||
if (!versionCheck.ok) {
|
||||
const result = await this.checkHealth();
|
||||
if (!result.ok && result.failure.kind === "version-check") {
|
||||
const { versionCheck } = result.failure;
|
||||
this.healthy = false;
|
||||
const details = [
|
||||
versionCheck.expectedVersion ? `expected=${versionCheck.expectedVersion}` : null,
|
||||
@@ -516,20 +569,12 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await spawnAndCollect(
|
||||
{
|
||||
command: this.config.command,
|
||||
args: ["--help"],
|
||||
cwd: this.config.cwd,
|
||||
stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars,
|
||||
},
|
||||
this.spawnCommandOptions,
|
||||
);
|
||||
if (result.error) {
|
||||
const spawnFailure = resolveSpawnFailure(result.error, this.config.cwd);
|
||||
if (!result.ok && result.failure.kind === "help-check") {
|
||||
const { result: helpResult } = result.failure;
|
||||
this.healthy = false;
|
||||
if (helpResult.error) {
|
||||
const spawnFailure = resolveSpawnFailure(helpResult.error, this.config.cwd);
|
||||
if (spawnFailure === "missing-command") {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
@@ -538,42 +583,47 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
};
|
||||
}
|
||||
if (spawnFailure === "missing-cwd") {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: `ACP runtime working directory does not exist: ${this.config.cwd}`,
|
||||
};
|
||||
}
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: result.error.message,
|
||||
details: [String(result.error)],
|
||||
message: helpResult.error.message,
|
||||
details: [String(helpResult.error)],
|
||||
};
|
||||
}
|
||||
if ((result.code ?? 0) !== 0) {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: result.stderr.trim() || `acpx exited with code ${result.code ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
this.healthy = true;
|
||||
return {
|
||||
ok: true,
|
||||
message: `acpx command available (${this.config.command}, version ${versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`,
|
||||
};
|
||||
} catch (error) {
|
||||
this.healthy = false;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message:
|
||||
helpResult.stderr.trim() || `acpx exited with code ${helpResult.code ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
this.healthy = false;
|
||||
const failure = result.failure;
|
||||
return {
|
||||
ok: false,
|
||||
code: "ACP_BACKEND_UNAVAILABLE",
|
||||
message:
|
||||
failure.kind === "exception"
|
||||
? failure.error instanceof Error
|
||||
? failure.error.message
|
||||
: String(failure.error)
|
||||
: "acpx backend unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
this.healthy = true;
|
||||
return {
|
||||
ok: true,
|
||||
message: `acpx command available (${this.config.command}, version ${result.versionCheck.version}${this.config.expectedVersion ? `, expected ${this.config.expectedVersion}` : ""})`,
|
||||
};
|
||||
}
|
||||
|
||||
async cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diffs",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw diff viewer plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,6 +22,45 @@ export type DownloadMessageResourceResult = {
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
|
||||
account: ReturnType<typeof resolveFeishuAccount>;
|
||||
client: ReturnType<typeof createFeishuClient>;
|
||||
} {
|
||||
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
return {
|
||||
account,
|
||||
client: createFeishuClient({
|
||||
...account,
|
||||
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function extractFeishuUploadKey(
|
||||
response: unknown,
|
||||
params: {
|
||||
key: "image_key" | "file_key";
|
||||
errorPrefix: string;
|
||||
},
|
||||
): string {
|
||||
// SDK v1.30+ returns data directly without code wrapper on success.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const key = responseAny[params.key] ?? responseAny.data?.[params.key];
|
||||
if (!key) {
|
||||
throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
async function readFeishuResponseBuffer(params: {
|
||||
response: unknown;
|
||||
tmpDirPrefix: string;
|
||||
@@ -94,15 +133,7 @@ export async function downloadImageFeishu(params: {
|
||||
if (!normalizedImageKey) {
|
||||
throw new Error("Feishu image download failed: invalid image_key");
|
||||
}
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient({
|
||||
...account,
|
||||
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
const response = await client.im.image.get({
|
||||
path: { image_key: normalizedImageKey },
|
||||
@@ -132,15 +163,7 @@ export async function downloadMessageResourceFeishu(params: {
|
||||
if (!normalizedFileKey) {
|
||||
throw new Error("Feishu message resource download failed: invalid file_key");
|
||||
}
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient({
|
||||
...account,
|
||||
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
const response = await client.im.messageResource.get({
|
||||
path: { message_id: messageId, file_key: normalizedFileKey },
|
||||
@@ -179,15 +202,7 @@ export async function uploadImageFeishu(params: {
|
||||
accountId?: string;
|
||||
}): Promise<UploadImageResult> {
|
||||
const { cfg, image, imageType = "message", accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient({
|
||||
...account,
|
||||
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
// SDK accepts Buffer directly or fs.ReadStream for file paths
|
||||
// Using Readable.from(buffer) causes issues with form-data library
|
||||
@@ -202,20 +217,12 @@ export async function uploadImageFeishu(params: {
|
||||
},
|
||||
});
|
||||
|
||||
// SDK v1.30+ returns data directly without code wrapper on success
|
||||
// On error, it throws or returns { code, msg }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
|
||||
if (!imageKey) {
|
||||
throw new Error("Feishu image upload failed: no image_key returned");
|
||||
}
|
||||
|
||||
return { imageKey };
|
||||
return {
|
||||
imageKey: extractFeishuUploadKey(response, {
|
||||
key: "image_key",
|
||||
errorPrefix: "Feishu image upload failed",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,15 +256,7 @@ export async function uploadFileFeishu(params: {
|
||||
accountId?: string;
|
||||
}): Promise<UploadFileResult> {
|
||||
const { cfg, file, fileName, fileType, duration, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient({
|
||||
...account,
|
||||
httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
|
||||
});
|
||||
const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
|
||||
|
||||
// SDK accepts Buffer directly or fs.ReadStream for file paths
|
||||
// Using Readable.from(buffer) causes issues with form-data library
|
||||
@@ -276,19 +275,12 @@ export async function uploadFileFeishu(params: {
|
||||
},
|
||||
});
|
||||
|
||||
// SDK v1.30+ returns data directly without code wrapper on success
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
|
||||
const responseAny = response as any;
|
||||
if (responseAny.code !== undefined && responseAny.code !== 0) {
|
||||
throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
|
||||
}
|
||||
|
||||
const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
|
||||
if (!fileKey) {
|
||||
throw new Error("Feishu file upload failed: no file_key returned");
|
||||
}
|
||||
|
||||
return { fileKey };
|
||||
return {
|
||||
fileKey: extractFeishuUploadKey(response, {
|
||||
key: "file_key",
|
||||
errorPrefix: "Feishu file upload failed",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,20 @@ export type FeishuReaction = {
|
||||
operatorId: string;
|
||||
};
|
||||
|
||||
function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) {
|
||||
const account = resolveFeishuAccount(params);
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
return createFeishuClient(account);
|
||||
}
|
||||
|
||||
function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) {
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a reaction (emoji) to a message.
|
||||
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
|
||||
@@ -21,12 +35,7 @@ export async function addReactionFeishu(params: {
|
||||
accountId?: string;
|
||||
}): Promise<{ reactionId: string }> {
|
||||
const { cfg, messageId, emojiType, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
||||
|
||||
const response = (await client.im.messageReaction.create({
|
||||
path: { message_id: messageId },
|
||||
@@ -41,9 +50,7 @@ export async function addReactionFeishu(params: {
|
||||
data?: { reaction_id?: string };
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
assertFeishuReactionApiSuccess(response, "add reaction");
|
||||
|
||||
const reactionId = response.data?.reaction_id;
|
||||
if (!reactionId) {
|
||||
@@ -63,12 +70,7 @@ export async function removeReactionFeishu(params: {
|
||||
accountId?: string;
|
||||
}): Promise<void> {
|
||||
const { cfg, messageId, reactionId, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
||||
|
||||
const response = (await client.im.messageReaction.delete({
|
||||
path: {
|
||||
@@ -77,9 +79,7 @@ export async function removeReactionFeishu(params: {
|
||||
},
|
||||
})) as { code?: number; msg?: string };
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
assertFeishuReactionApiSuccess(response, "remove reaction");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,12 +92,7 @@ export async function listReactionsFeishu(params: {
|
||||
accountId?: string;
|
||||
}): Promise<FeishuReaction[]> {
|
||||
const { cfg, messageId, emojiType, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
||||
|
||||
const response = (await client.im.messageReaction.list({
|
||||
path: { message_id: messageId },
|
||||
@@ -115,9 +110,7 @@ export async function listReactionsFeishu(params: {
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
||||
}
|
||||
assertFeishuReactionApiSuccess(response, "list reactions");
|
||||
|
||||
const items = response.data?.items ?? [];
|
||||
return items.map((item) => ({
|
||||
|
||||
@@ -43,6 +43,10 @@ function isWithdrawnReplyError(err: unknown): boolean {
|
||||
type FeishuCreateMessageClient = {
|
||||
im: {
|
||||
message: {
|
||||
reply: (opts: {
|
||||
path: { message_id: string };
|
||||
data: { content: string; msg_type: string; reply_in_thread?: true };
|
||||
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
|
||||
create: (opts: {
|
||||
params: { receive_id_type: "chat_id" | "email" | "open_id" | "union_id" | "user_id" };
|
||||
data: { receive_id: string; content: string; msg_type: string };
|
||||
@@ -74,6 +78,50 @@ async function sendFallbackDirect(
|
||||
return toFeishuSendResult(response, params.receiveId);
|
||||
}
|
||||
|
||||
async function sendReplyOrFallbackDirect(
|
||||
client: FeishuCreateMessageClient,
|
||||
params: {
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
content: string;
|
||||
msgType: string;
|
||||
directParams: {
|
||||
receiveId: string;
|
||||
receiveIdType: "chat_id" | "email" | "open_id" | "union_id" | "user_id";
|
||||
content: string;
|
||||
msgType: string;
|
||||
};
|
||||
directErrorPrefix: string;
|
||||
replyErrorPrefix: string;
|
||||
},
|
||||
): Promise<FeishuSendResult> {
|
||||
if (!params.replyToMessageId) {
|
||||
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
|
||||
}
|
||||
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
path: { message_id: params.replyToMessageId },
|
||||
data: {
|
||||
content: params.content,
|
||||
msg_type: params.msgType,
|
||||
...(params.replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
|
||||
}
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
return sendFallbackDirect(client, params.directParams, params.directErrorPrefix);
|
||||
}
|
||||
assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
|
||||
return toFeishuSendResult(response, params.directParams.receiveId);
|
||||
}
|
||||
|
||||
function parseInteractiveCardContent(parsed: unknown): string {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return "[Interactive Card]";
|
||||
@@ -290,32 +338,15 @@ export async function sendMessageFeishu(
|
||||
const { content, msgType } = buildFeishuPostMessagePayload({ messageText });
|
||||
|
||||
const directParams = { receiveId, receiveIdType, content, msgType };
|
||||
|
||||
if (replyToMessageId) {
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: msgType,
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
return sendFallbackDirect(client, directParams, "Feishu send failed");
|
||||
}
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
return sendFallbackDirect(client, directParams, "Feishu send failed");
|
||||
}
|
||||
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
}
|
||||
|
||||
return sendFallbackDirect(client, directParams, "Feishu send failed");
|
||||
return sendReplyOrFallbackDirect(client, {
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
content,
|
||||
msgType,
|
||||
directParams,
|
||||
directErrorPrefix: "Feishu send failed",
|
||||
replyErrorPrefix: "Feishu reply failed",
|
||||
});
|
||||
}
|
||||
|
||||
export type SendFeishuCardParams = {
|
||||
@@ -334,32 +365,15 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
const directParams = { receiveId, receiveIdType, content, msgType: "interactive" };
|
||||
|
||||
if (replyToMessageId) {
|
||||
let response: { code?: number; msg?: string; data?: { message_id?: string } };
|
||||
try {
|
||||
response = await client.im.message.reply({
|
||||
path: { message_id: replyToMessageId },
|
||||
data: {
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isWithdrawnReplyError(err)) {
|
||||
throw err;
|
||||
}
|
||||
return sendFallbackDirect(client, directParams, "Feishu card send failed");
|
||||
}
|
||||
if (shouldFallbackFromReplyTarget(response)) {
|
||||
return sendFallbackDirect(client, directParams, "Feishu card send failed");
|
||||
}
|
||||
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
||||
return toFeishuSendResult(response, receiveId);
|
||||
}
|
||||
|
||||
return sendFallbackDirect(client, directParams, "Feishu card send failed");
|
||||
return sendReplyOrFallbackDirect(client, {
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
content,
|
||||
msgType: "interactive",
|
||||
directParams,
|
||||
directErrorPrefix: "Feishu card send failed",
|
||||
replyErrorPrefix: "Feishu card reply failed",
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCardFeishu(params: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
101
extensions/msteams/src/graph-upload.test.ts
Normal file
101
extensions/msteams/src/graph-upload.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { uploadToOneDrive, uploadToSharePoint } from "./graph-upload.js";
|
||||
|
||||
describe("graph upload helpers", () => {
|
||||
const tokenProvider = {
|
||||
getAccessToken: vi.fn(async () => "graph-token"),
|
||||
};
|
||||
|
||||
it("uploads to OneDrive with the personal drive path", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ id: "item-1", webUrl: "https://example.com/1", name: "a.txt" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await uploadToOneDrive({
|
||||
buffer: Buffer.from("hello"),
|
||||
filename: "a.txt",
|
||||
tokenProvider,
|
||||
fetchFn: fetchFn as typeof fetch,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"https://graph.microsoft.com/v1.0/me/drive/root:/OpenClawShared/a.txt:/content",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer graph-token",
|
||||
"Content-Type": "application/octet-stream",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
id: "item-1",
|
||||
webUrl: "https://example.com/1",
|
||||
name: "a.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("uploads to SharePoint with the site drive path", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({ id: "item-2", webUrl: "https://example.com/2", name: "b.txt" }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await uploadToSharePoint({
|
||||
buffer: Buffer.from("world"),
|
||||
filename: "b.txt",
|
||||
siteId: "site-123",
|
||||
tokenProvider,
|
||||
fetchFn: fetchFn as typeof fetch,
|
||||
});
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"https://graph.microsoft.com/v1.0/sites/site-123/drive/root:/OpenClawShared/b.txt:/content",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer graph-token",
|
||||
"Content-Type": "application/octet-stream",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
id: "item-2",
|
||||
webUrl: "https://example.com/2",
|
||||
name: "b.txt",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects upload responses missing required fields", async () => {
|
||||
const fetchFn = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ id: "item-3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uploadToSharePoint({
|
||||
buffer: Buffer.from("world"),
|
||||
filename: "bad.txt",
|
||||
siteId: "site-123",
|
||||
tokenProvider,
|
||||
fetchFn: fetchFn as typeof fetch,
|
||||
}),
|
||||
).rejects.toThrow("SharePoint upload response missing required fields");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,53 @@ export interface OneDriveUploadResult {
|
||||
name: string;
|
||||
}
|
||||
|
||||
function parseUploadedDriveItem(
|
||||
data: { id?: string; webUrl?: string; name?: string },
|
||||
label: "OneDrive" | "SharePoint",
|
||||
): OneDriveUploadResult {
|
||||
if (!data.id || !data.webUrl || !data.name) {
|
||||
throw new Error(`${label} upload response missing required fields`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
webUrl: data.webUrl,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadDriveItem(params: {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
url: string;
|
||||
label: "OneDrive" | "SharePoint";
|
||||
}): Promise<OneDriveUploadResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
const res = await fetchFn(params.url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`${params.label} upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
return parseUploadedDriveItem(
|
||||
(await res.json()) as { id?: string; webUrl?: string; name?: string },
|
||||
params.label,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the user's OneDrive root folder.
|
||||
* For larger files, this uses the simple upload endpoint (up to 4MB).
|
||||
@@ -32,41 +79,13 @@ export async function uploadToOneDrive(params: {
|
||||
tokenProvider: MSTeamsAccessTokenProvider;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveUploadResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
// Use "OpenClawShared" folder to organize bot-uploaded files
|
||||
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
|
||||
|
||||
const res = await fetchFn(`${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
return await uploadDriveItem({
|
||||
...params,
|
||||
url: `${GRAPH_ROOT}/me/drive/root:${uploadPath}:/content`,
|
||||
label: "OneDrive",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`OneDrive upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
webUrl?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!data.id || !data.webUrl || !data.name) {
|
||||
throw new Error("OneDrive upload response missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
webUrl: data.webUrl,
|
||||
name: data.name,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OneDriveSharingLink {
|
||||
@@ -175,44 +194,13 @@ export async function uploadToSharePoint(params: {
|
||||
siteId: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<OneDriveUploadResult> {
|
||||
const fetchFn = params.fetchFn ?? fetch;
|
||||
const token = await params.tokenProvider.getAccessToken(GRAPH_SCOPE);
|
||||
|
||||
// Use "OpenClawShared" folder to organize bot-uploaded files
|
||||
const uploadPath = `/OpenClawShared/${encodeURIComponent(params.filename)}`;
|
||||
|
||||
const res = await fetchFn(
|
||||
`${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": params.contentType ?? "application/octet-stream",
|
||||
},
|
||||
body: new Uint8Array(params.buffer),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`SharePoint upload failed: ${res.status} ${res.statusText} - ${body}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
webUrl?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
if (!data.id || !data.webUrl || !data.name) {
|
||||
throw new Error("SharePoint upload response missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
webUrl: data.webUrl,
|
||||
name: data.name,
|
||||
};
|
||||
return await uploadDriveItem({
|
||||
...params,
|
||||
url: `${GRAPH_ROOT}/sites/${params.siteId}/drive/root:${uploadPath}:/content`,
|
||||
label: "SharePoint",
|
||||
});
|
||||
}
|
||||
|
||||
export interface ChatMember {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
28
extensions/nextcloud-talk/src/normalize.test.ts
Normal file
28
extensions/nextcloud-talk/src/normalize.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
stripNextcloudTalkTargetPrefix,
|
||||
} from "./normalize.js";
|
||||
|
||||
describe("nextcloud-talk target normalization", () => {
|
||||
it("strips supported prefixes to a room token", () => {
|
||||
expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
|
||||
expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
|
||||
expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
|
||||
expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
|
||||
expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes messaging targets to lowercase channel ids", () => {
|
||||
expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
|
||||
expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
|
||||
});
|
||||
|
||||
it("detects prefixed and bare room ids", () => {
|
||||
expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
|
||||
expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
||||
export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
@@ -22,7 +22,12 @@ export function normalizeNextcloudTalkMessagingTarget(raw: string): string | und
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `nextcloud-talk:${normalized}`.toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
||||
const normalized = stripNextcloudTalkTargetPrefix(raw);
|
||||
return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { generateNextcloudTalkSignature } from "./signature.js";
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
@@ -34,33 +35,19 @@ function resolveCredentials(
|
||||
}
|
||||
|
||||
function normalizeRoomToken(to: string): string {
|
||||
const trimmed = to.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Room token is required for Nextcloud Talk sends");
|
||||
}
|
||||
|
||||
let normalized = trimmed;
|
||||
if (normalized.startsWith("nextcloud-talk:")) {
|
||||
normalized = normalized.slice("nextcloud-talk:".length).trim();
|
||||
} else if (normalized.startsWith("nc:")) {
|
||||
normalized = normalized.slice("nc:".length).trim();
|
||||
}
|
||||
|
||||
if (normalized.startsWith("room:")) {
|
||||
normalized = normalized.slice("room:".length).trim();
|
||||
}
|
||||
|
||||
const normalized = stripNextcloudTalkTargetPrefix(to);
|
||||
if (!normalized) {
|
||||
throw new Error("Room token is required for Nextcloud Talk sends");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function sendMessageNextcloudTalk(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: NextcloudTalkSendOpts = {},
|
||||
): Promise<NextcloudTalkSendResult> {
|
||||
function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): {
|
||||
cfg: CoreConfig;
|
||||
account: ReturnType<typeof resolveNextcloudTalkAccount>;
|
||||
baseUrl: string;
|
||||
secret: string;
|
||||
} {
|
||||
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
@@ -70,6 +57,15 @@ export async function sendMessageNextcloudTalk(
|
||||
{ baseUrl: opts.baseUrl, secret: opts.secret },
|
||||
account,
|
||||
);
|
||||
return { cfg, account, baseUrl, secret };
|
||||
}
|
||||
|
||||
export async function sendMessageNextcloudTalk(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: NextcloudTalkSendOpts = {},
|
||||
): Promise<NextcloudTalkSendResult> {
|
||||
const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
|
||||
const roomToken = normalizeRoomToken(to);
|
||||
|
||||
if (!text?.trim()) {
|
||||
@@ -176,15 +172,7 @@ export async function sendReactionNextcloudTalk(
|
||||
reaction: string,
|
||||
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
|
||||
): Promise<{ ok: true }> {
|
||||
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, secret } = resolveCredentials(
|
||||
{ baseUrl: opts.baseUrl, secret: opts.secret },
|
||||
account,
|
||||
);
|
||||
const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
|
||||
const normalizedToken = normalizeRoomToken(roomToken);
|
||||
|
||||
const body = JSON.stringify({ reaction });
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/ollama-provider",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Ollama provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/open-prose",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/sglang-provider",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw SGLang provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/slack",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Slack channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/synology-chat",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "Synology Chat channel plugin for OpenClaw",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/telegram",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/tlon",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -162,41 +162,55 @@ export function isGroupInviteAllowed(
|
||||
}
|
||||
|
||||
// Helper to recursively extract text from inline content
|
||||
function renderInlineItem(
|
||||
item: any,
|
||||
options?: {
|
||||
linkMode?: "content-or-href" | "href";
|
||||
allowBreak?: boolean;
|
||||
allowBlockquote?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (!item || typeof item !== "object") {
|
||||
return "";
|
||||
}
|
||||
if (item.ship) {
|
||||
return item.ship;
|
||||
}
|
||||
if ("sect" in item) {
|
||||
return `@${item.sect || "all"}`;
|
||||
}
|
||||
if (options?.allowBreak && item.break !== undefined) {
|
||||
return "\n";
|
||||
}
|
||||
if (item["inline-code"]) {
|
||||
return `\`${item["inline-code"]}\``;
|
||||
}
|
||||
if (item.code) {
|
||||
return `\`${item.code}\``;
|
||||
}
|
||||
if (item.link && item.link.href) {
|
||||
return options?.linkMode === "href" ? item.link.href : item.link.content || item.link.href;
|
||||
}
|
||||
if (item.bold && Array.isArray(item.bold)) {
|
||||
return `**${extractInlineText(item.bold)}**`;
|
||||
}
|
||||
if (item.italics && Array.isArray(item.italics)) {
|
||||
return `*${extractInlineText(item.italics)}*`;
|
||||
}
|
||||
if (item.strike && Array.isArray(item.strike)) {
|
||||
return `~~${extractInlineText(item.strike)}~~`;
|
||||
}
|
||||
if (options?.allowBlockquote && item.blockquote && Array.isArray(item.blockquote)) {
|
||||
return `> ${extractInlineText(item.blockquote)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractInlineText(items: any[]): string {
|
||||
return items
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) {
|
||||
return item.ship;
|
||||
}
|
||||
if ("sect" in item) {
|
||||
return `@${item.sect || "all"}`;
|
||||
}
|
||||
if (item["inline-code"]) {
|
||||
return `\`${item["inline-code"]}\``;
|
||||
}
|
||||
if (item.code) {
|
||||
return `\`${item.code}\``;
|
||||
}
|
||||
if (item.link && item.link.href) {
|
||||
return item.link.content || item.link.href;
|
||||
}
|
||||
if (item.bold && Array.isArray(item.bold)) {
|
||||
return `**${extractInlineText(item.bold)}**`;
|
||||
}
|
||||
if (item.italics && Array.isArray(item.italics)) {
|
||||
return `*${extractInlineText(item.italics)}*`;
|
||||
}
|
||||
if (item.strike && Array.isArray(item.strike)) {
|
||||
return `~~${extractInlineText(item.strike)}~~`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
return items.map((item: any) => renderInlineItem(item)).join("");
|
||||
}
|
||||
|
||||
export function extractMessageText(content: unknown): string {
|
||||
@@ -209,48 +223,13 @@ export function extractMessageText(content: unknown): string {
|
||||
// Handle inline content (text, ships, links, etc.)
|
||||
if (verse.inline && Array.isArray(verse.inline)) {
|
||||
return verse.inline
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) {
|
||||
return item.ship;
|
||||
}
|
||||
// Handle sect (role mentions like @all)
|
||||
if ("sect" in item) {
|
||||
return `@${item.sect || "all"}`;
|
||||
}
|
||||
if (item.break !== undefined) {
|
||||
return "\n";
|
||||
}
|
||||
if (item.link && item.link.href) {
|
||||
return item.link.href;
|
||||
}
|
||||
// Handle inline code (Tlon uses "inline-code" key)
|
||||
if (item["inline-code"]) {
|
||||
return `\`${item["inline-code"]}\``;
|
||||
}
|
||||
if (item.code) {
|
||||
return `\`${item.code}\``;
|
||||
}
|
||||
// Handle bold/italic/strike - recursively extract text
|
||||
if (item.bold && Array.isArray(item.bold)) {
|
||||
return `**${extractInlineText(item.bold)}**`;
|
||||
}
|
||||
if (item.italics && Array.isArray(item.italics)) {
|
||||
return `*${extractInlineText(item.italics)}*`;
|
||||
}
|
||||
if (item.strike && Array.isArray(item.strike)) {
|
||||
return `~~${extractInlineText(item.strike)}~~`;
|
||||
}
|
||||
// Handle blockquote inline
|
||||
if (item.blockquote && Array.isArray(item.blockquote)) {
|
||||
return `> ${extractInlineText(item.blockquote)}`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.map((item: any) =>
|
||||
renderInlineItem(item, {
|
||||
linkMode: "href",
|
||||
allowBreak: true,
|
||||
allowBlockquote: true,
|
||||
}),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,29 @@ export type UrbitChannelDeps = {
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
async function putUrbitChannel(
|
||||
deps: UrbitChannelDeps,
|
||||
params: { body: unknown; auditContext: string },
|
||||
) {
|
||||
return await urbitFetch({
|
||||
baseUrl: deps.baseUrl,
|
||||
path: `/~/channel/${deps.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: deps.cookie,
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
ssrfPolicy: deps.ssrfPolicy,
|
||||
lookupFn: deps.lookupFn,
|
||||
fetchImpl: deps.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: params.auditContext,
|
||||
});
|
||||
}
|
||||
|
||||
export async function pokeUrbitChannel(
|
||||
deps: UrbitChannelDeps,
|
||||
params: { app: string; mark: string; json: unknown; auditContext: string },
|
||||
@@ -26,21 +49,8 @@ export async function pokeUrbitChannel(
|
||||
json: params.json,
|
||||
};
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: deps.baseUrl,
|
||||
path: `/~/channel/${deps.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: deps.cookie,
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
},
|
||||
ssrfPolicy: deps.ssrfPolicy,
|
||||
lookupFn: deps.lookupFn,
|
||||
fetchImpl: deps.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
const { response, release } = await putUrbitChannel(deps, {
|
||||
body: [pokeData],
|
||||
auditContext: params.auditContext,
|
||||
});
|
||||
|
||||
@@ -88,23 +98,7 @@ export async function createUrbitChannel(
|
||||
deps: UrbitChannelDeps,
|
||||
params: { body: unknown; auditContext: string },
|
||||
): Promise<void> {
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: deps.baseUrl,
|
||||
path: `/~/channel/${deps.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: deps.cookie,
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
ssrfPolicy: deps.ssrfPolicy,
|
||||
lookupFn: deps.lookupFn,
|
||||
fetchImpl: deps.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: params.auditContext,
|
||||
});
|
||||
const { response, release } = await putUrbitChannel(deps, params);
|
||||
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
@@ -116,30 +110,17 @@ export async function createUrbitChannel(
|
||||
}
|
||||
|
||||
export async function wakeUrbitChannel(deps: UrbitChannelDeps): Promise<void> {
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: deps.baseUrl,
|
||||
path: `/~/channel/${deps.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: deps.cookie,
|
||||
const { response, release } = await putUrbitChannel(deps, {
|
||||
body: [
|
||||
{
|
||||
id: Date.now(),
|
||||
action: "poke",
|
||||
ship: deps.ship,
|
||||
app: "hood",
|
||||
mark: "helm-hi",
|
||||
json: "Opening API channel",
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
id: Date.now(),
|
||||
action: "poke",
|
||||
ship: deps.ship,
|
||||
app: "hood",
|
||||
mark: "helm-hi",
|
||||
json: "Opening API channel",
|
||||
},
|
||||
]),
|
||||
},
|
||||
ssrfPolicy: deps.ssrfPolicy,
|
||||
lookupFn: deps.lookupFn,
|
||||
fetchImpl: deps.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
],
|
||||
auditContext: "tlon-urbit-channel-wake",
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,36 @@ vi.mock("@tloncorp/api", () => ({
|
||||
}));
|
||||
|
||||
describe("uploadImageFromUrl", () => {
|
||||
async function loadUploadMocks() {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
return {
|
||||
mockFetch: vi.mocked(fetchWithSsrFGuard),
|
||||
mockUploadFile: vi.mocked(uploadFile),
|
||||
uploadImageFromUrl,
|
||||
};
|
||||
}
|
||||
|
||||
type UploadMocks = Awaited<ReturnType<typeof loadUploadMocks>>;
|
||||
|
||||
function mockSuccessfulFetch(params: {
|
||||
mockFetch: UploadMocks["mockFetch"];
|
||||
blob: Blob;
|
||||
finalUrl: string;
|
||||
contentType: string;
|
||||
}) {
|
||||
params.mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": params.contentType }),
|
||||
blob: () => Promise.resolve(params.blob),
|
||||
} as unknown as Response,
|
||||
finalUrl: params.finalUrl,
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -24,28 +54,17 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("fetches image and calls uploadFile, returns uploaded URL", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a successful response with a blob
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// Mock uploadFile to return a successful upload
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://memex.tlon.network/uploaded.png");
|
||||
@@ -59,10 +78,8 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("returns original URL if fetch fails", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
const { mockFetch, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a failed response
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: false,
|
||||
@@ -72,35 +89,23 @@ describe("uploadImageFromUrl", () => {
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("returns original URL if upload fails", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a successful response
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// Mock uploadFile to throw an error
|
||||
mockUploadFile.mockRejectedValue(new Error("Upload failed"));
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://example.com/image.png");
|
||||
@@ -127,26 +132,18 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("extracts filename from URL path", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/jpeg" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/path/to/my-image.jpg",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
await uploadImageFromUrl("https://example.com/path/to/my-image.jpg");
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
@@ -157,26 +154,18 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("uses default filename when URL has no path", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
await uploadImageFromUrl("https://example.com/");
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw Twitch channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/vllm-provider",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw vLLM provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"description": "OpenClaw voice-call plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/whatsapp",
|
||||
"version": "2026.3.12",
|
||||
"version": "2026.3.13",
|
||||
"private": true,
|
||||
"description": "OpenClaw WhatsApp channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user