diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index 5ea0373ff76..41ca9eb98b0 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -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" diff --git a/.github/actions/setup-pnpm-store-cache/action.yml b/.github/actions/setup-pnpm-store-cache/action.yml index 249544d49ac..2f7c992a978 100644 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ b/.github/actions/setup-pnpm-store-cache/action.yml @@ -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') }} diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index d9d810bffa7..69dff002c7b 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -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: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9038096a488..2761a7b0d3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 1d8e473af4f..79c041ef727 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -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" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 3ad4b539311..f4128cddc88 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -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 }} diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index ca04748f9bf..f48c794b668 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -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. diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8de54a416f8..3a38e5213c3 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -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: | diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index f3783045820..ac0a8f728e3 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -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 diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 8ece9010a20..4a839b4d878 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index e6feef90e6b..95dc406da45 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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: | diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index 19668e697ad..9426f678926 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -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 diff --git a/.gitignore b/.gitignore index 4f8abcaa94f..0eabcb6843c 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0697d588893..6d1288649a1 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4bb0e17361..87ccbeff4ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/appcast.xml b/appcast.xml index 4bceb205614..69632c08b97 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,98 @@ OpenClaw + + 2026.3.12 + Fri, 13 Mar 2026 04:25:50 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 2026031290 + 2026.3.12 + 15.0 + OpenClaw 2026.3.12 +

Changes

+
    +
  • 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.
  • +
  • OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across /fast, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.
  • +
  • Anthropic/Claude fast mode: map the shared /fast toggle and params.fastMode to direct Anthropic API-key service_tier requests, with live verification for both Anthropic and OpenAI fast-mode tiers.
  • +
  • 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.
  • +
  • Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
  • +
  • Agents/subagents: add sessions_yield 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
  • +
  • Slack/agent replies: support channelData.slack.blocks in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
  • +
+

Fixes

+
    +
  • Security/device pairing: switch /pair and openclaw qr 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.
  • +
  • Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (GHSA-99qw-6mr3-36qr)(#44174) Thanks @lintsinghua and @vincentkoc.
  • +
  • Models/Kimi Coding: send anthropic-messages tools in native Anthropic format again so kimi-coding stops degrading tool calls into XML/plain-text pseudo invocations instead of real tool_use blocks. (#38669, #39907, #40552) Thanks @opriz.
  • +
  • TUI/chat log: reuse the active assistant message component for the same streaming run so openclaw tui no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.
  • +
  • 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.
  • +
  • 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.
  • +
  • Models/Kimi Coding: send the built-in User-Agent: claude-code/0.1.0 header by default for kimi-coding 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.
  • +
  • Models/OpenAI Codex Spark: keep gpt-5.3-codex-spark working on the openai-codex/* path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct openai/* Spark row that OpenAI rejects live.
  • +
  • Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like kimi-k2.5:cloud, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
  • +
  • Moonshot CN API: respect explicit baseUrl (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.
  • +
  • Kimi Coding/provider config: respect explicit models.providers["kimi-coding"].baseUrl when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.
  • +
  • Gateway/main-session routing: keep TUI and other mode:UI main-session sends on the internal surface when deliver is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.
  • +
  • BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching fromMe 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.
  • +
  • iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching is_from_me event was just seen for the same chat, text, and created_at, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
  • +
  • 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.
  • +
  • Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding replyToId from the block reply dedup key and adding an explicit threading dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.
  • +
  • Mattermost/reply media delivery: pass agent-scoped mediaLocalRoots through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
  • +
  • macOS/Reminders: add the missing NSRemindersUsageDescription to the bundled app so apple-reminders can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
  • +
  • Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated session.store roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
  • +
  • Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process HOME/OPENCLAW_HOME changes no longer reuse stale plugin state or misreport ~/... plugins as untracked. (#44046) thanks @gumadeiras.
  • +
  • Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and models list --plain, and migrate legacy duplicated openrouter/openrouter/... config entries forward on write.
  • +
  • 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 openclaw update no longer dies early on missing git or node-llama-cpp download setup.
  • +
  • Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed write no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
  • +
  • Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible \u{...} escapes instead of spoofing the reviewed command. (GHSA-pcqg-f7rg-xfvv)(#43687) Thanks @EkiXu and @vincentkoc.
  • +
  • Hooks/loader: fail closed when workspace hook paths cannot be resolved with realpath, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.
  • +
  • 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.
  • +
  • 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. (GHSA-9r3v-37xh-2cf6)(#44091) Thanks @wooluo and @vincentkoc.
  • +
  • Security/exec allowlist: preserve POSIX case sensitivity and keep ? within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (GHSA-f8r2-vg7x-gh8m)(#43798) Thanks @zpbrent and @vincentkoc.
  • +
  • Security/commands: require sender ownership for /config and /debug so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (GHSA-r7vr-gr74-94p8)(#44305) Thanks @tdjackey and @vincentkoc.
  • +
  • Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (GHSA-rqpp-rjj8-7wv8)(#44306) Thanks @LUOYEcode and @vincentkoc.
  • +
  • Security/browser.request: block persistent browser profile create/delete routes from write-scoped browser.request so callers can no longer persist admin-only browser profile changes through the browser control surface. (GHSA-vmhq-cqm9-6p7q)(#43800) Thanks @tdjackey and @vincentkoc.
  • +
  • Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external agent callers can no longer override the gateway workspace boundary. (GHSA-2rqg-gjgv-84jm)(#43801) Thanks @tdjackey and @vincentkoc.
  • +
  • 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 session_status. (GHSA-wcxr-59v9-rxr8)(#43754) Thanks @tdjackey and @vincentkoc.
  • +
  • Security/agent tools: mark nodes as explicitly owner-only and document/test that canvas remains a shared trusted-operator surface unless a real boundary bypass exists.
  • +
  • Security/exec approvals: fail closed for Ruby approval flows that use -r, --require, or -I so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.
  • +
  • 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. (GHSA-2pwv-x786-56f8)(#43686) Thanks @tdjackey and @vincentkoc.
  • +
  • Docs/onboarding: align the legacy wizard reference and openclaw onboard command docs with the Ollama onboarding flow so all onboarding reference paths now document --auth-choice ollama, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.
  • +
  • Models/secrets: enforce source-managed SecretRef markers in generated models.json so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.
  • +
  • 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. (GHSA-jv4g-m82p-2j93)(#44089) (GHSA-xwx2-ppv2-wx98)(#44089) Thanks @ez-lbz and @vincentkoc.
  • +
  • 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. (GHSA-6rph-mmhp-h7h9)(#43684) Thanks @tdjackey and @vincentkoc.
  • +
  • Security/host env: block inherited GIT_EXEC_PATH from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (GHSA-jf5v-pqgw-gm5m)(#43685) Thanks @zpbrent and @vincentkoc.
  • +
  • Security/Feishu webhook: require encryptKey alongside verificationToken in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (GHSA-g353-mgv3-8pcj)(#44087) Thanks @lintsinghua and @vincentkoc.
  • +
  • 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 p2p reactions. (GHSA-m69h-jm2f-2pv8)(#44088) Thanks @zpbrent and @vincentkoc.
  • +
  • Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a 200 response. (GHSA-mhxh-9pjm-w7q5)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.
  • +
  • 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 429 responses. (GHSA-5m9r-p9g7-679c)(#44173) Thanks @zpbrent and @vincentkoc.
  • +
  • Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind channels.zalouser.dangerouslyAllowNameMatching. Thanks @zpbrent.
  • +
  • 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 dangerouslyAllowNameMatching break-glass flag.
  • +
  • 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 pnpm/npm exec/npx script runners before approval binding. (GHSA-57jw-9722-6rf2)(GHSA-jvqh-rfmh-jh27)(GHSA-x7pp-23xv-mmr4)(GHSA-jc5j-vg4r-j5jx)(#44247) Thanks @tdjackey and @vincentkoc.
  • +
  • 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.
  • +
  • Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
  • +
  • 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.
  • +
  • Context engine/session routing: forward optional sessionKey through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.
  • +
  • Agents/failover: classify z.ai network_error stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.
  • +
  • Memory/session sync: add mode-aware post-compaction session reindexing with agents.defaults.compaction.postIndexSync plus agents.defaults.memorySearch.sync.sessions.postCompactionForce, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.
  • +
  • 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.
  • +
  • Telegram/native command sync: suppress expected BOT_COMMANDS_TOO_MUCH retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.
  • +
  • Mattermost/reply media delivery: pass agent-scoped mediaLocalRoots through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
  • +
  • Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process HOME/OPENCLAW_HOME changes no longer reuse stale plugin state or misreport ~/... plugins as untracked. (#44046) thanks @gumadeiras.
  • +
  • Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated session.store roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
  • +
  • Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and models list --plain, and migrate legacy duplicated openrouter/openrouter/... config entries forward on write.
  • +
  • Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when hooks.allowedAgentIds leaves hook routing unrestricted.
  • +
  • Agents/compaction: skip the post-compaction cache-ttl marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
  • +
  • Native chat/macOS: add /new, /reset, and /clear 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.
  • +
  • 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.
  • +
+

View full changelog

+]]>
+ +
2026.3.8-beta.1 Mon, 09 Mar 2026 07:19:57 +0000 @@ -438,225 +530,5 @@ ]]> - - 2026.3.2 - Tue, 03 Mar 2026 04:30:29 +0000 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 2026030290 - 2026.3.2 - 15.0 - OpenClaw 2026.3.2 -

Changes

-
    -
  • Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, openclaw secrets planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.
  • -
  • Tools/PDF analysis: add a first-class pdf tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (agents.defaults.pdfModel, pdfMaxBytesMb, pdfMaxPages), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
  • -
  • Outbound adapters/plugins: add shared sendPayload support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
  • -
  • Models/MiniMax: add first-class MiniMax-M2.5-highspeed support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy MiniMax-M2.5-Lightning compatibility for existing configs.
  • -
  • Sessions/Attachments: add inline file attachment support for sessions_spawn (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via tools.sessions_spawn.attachments. (#16761) Thanks @napetrov.
  • -
  • Telegram/Streaming defaults: default channels.telegram.streaming to partial (from off) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
  • -
  • Telegram/DM streaming: use sendMessageDraft for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
  • -
  • Telegram/voice mention gating: add optional disableAudioPreflight on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
  • -
  • CLI/Config validation: add openclaw config validate (with --json) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
  • -
  • Tools/Diffs: add PDF file output support and rendering quality customization controls (fileQuality, fileScale, fileMaxWidth) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
  • -
  • Memory/Ollama embeddings: add memorySearch.provider = "ollama" and memorySearch.fallback = "ollama" support, honor models.providers.ollama settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
  • -
  • Zalo Personal plugin (@openclaw/zalouser): rebuilt channel runtime to use native zca-js integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
  • -
  • Plugin SDK/channel extensibility: expose channelRuntime on ChannelGatewayContext so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
  • -
  • Plugin runtime/STT: add api.runtime.stt.transcribeAudioFile(...) so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
  • -
  • Plugin hooks/session lifecycle: include sessionKey in session_start/session_end hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
  • -
  • Hooks/message lifecycle: add internal hook events message:transcribed and message:preprocessed, plus richer outbound message:sent context (isGroup, groupId) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
  • -
  • Media understanding/audio echo: add optional tools.media.audio.echoTranscript + echoFormat to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
  • -
  • Plugin runtime/system: expose runtime.system.requestHeartbeatNow(...) so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
  • -
  • Plugin runtime/events: expose runtime.events.onAgentEvent and runtime.events.onSessionTranscriptUpdate for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
  • -
  • CLI/Banner taglines: add cli.banner.taglineMode (random | default | off) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
  • -
-

Breaking

-
    -
  • BREAKING: Onboarding now defaults tools.profile to messaging for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
  • -
  • BREAKING: ACP dispatch now defaults to enabled unless explicitly disabled (acp.dispatch.enabled=false). If you need to pause ACP turn routing while keeping /acp controls, set acp.dispatch.enabled=false. Docs: https://docs.openclaw.ai/tools/acp-agents
  • -
  • BREAKING: Plugin SDK removed api.registerHttpHandler(...). Plugins must register explicit HTTP routes via api.registerHttpRoute({ path, auth, match, handler }), and dynamic webhook lifecycles should use registerPluginHttpRoute(...).
  • -
  • BREAKING: Zalo Personal plugin (@openclaw/zalouser) no longer depends on external zca-compatible CLI binaries (openzca, zca-cli) for runtime send/listen/login; operators should use openclaw channels login --channel zalouser after upgrade to refresh sessions in the new JS-native path.
  • -
-

Fixes

-
    -
  • Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (trim on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
  • -
  • Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing token.trim() crashes during status/start flows. (#31973) Thanks @ningding97.
  • -
  • Discord/lifecycle startup status: push an immediate connected status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
  • -
  • Feishu/LINE group system prompts: forward per-group systemPrompt config into inbound context GroupSystemPrompt for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.
  • -
  • Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
  • -
  • Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older openclaw/plugin-sdk builds omit webhook default constants. (#31606)
  • -
  • Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.
  • -
  • Gateway/Subagent TLS pairing: allow authenticated local gateway-client backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring sessions_spawn with gateway.tls.enabled=true in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.
  • -
  • Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
  • -
  • Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.
  • -
  • Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
  • -
  • Voice-call/runtime lifecycle: prevent EADDRINUSE loops by resetting failed runtime promises, making webhook start() idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.
  • -
  • Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example (a|aa)+), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
  • -
  • Gateway/Plugin HTTP hardening: require explicit auth for plugin route registration, add route ownership guards for duplicate path+match registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.
  • -
  • Browser/Profile defaults: prefer openclaw profile over chrome in headless/no-sandbox environments unless an explicit defaultProfile is configured. (#14944) Thanks @BenediktSchackenberg.
  • -
  • Gateway/WS security: keep plaintext ws:// loopback-only by default, with explicit break-glass private-network opt-in via OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.
  • -
  • OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit doctor --deep) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.
  • -
  • Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
  • -
  • CLI/Config validation and routing hardening: dedupe openclaw config validate failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including --json fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed config get/unset with split root options). Thanks @gumadeiras.
  • -
  • Browser/Extension relay reconnect tolerance: keep /json/version and /cdp reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.
  • -
  • CLI/Browser start timeout: honor openclaw browser --timeout start and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
  • -
  • Synology Chat/gateway lifecycle: keep startAccount pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
  • -
  • Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like /usr/bin/g++ and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
  • -
  • Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with 204 to avoid persistent Processing... states in Synology Chat clients. (#26635) Thanks @memphislee09-source.
  • -
  • Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
  • -
  • Slack/Bolt startup compatibility: remove invalid message.channels and message.groups event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified message handler (channel_type). (#32033) Thanks @mahopan.
  • -
  • Slack/socket auth failure handling: fail fast on non-recoverable auth errors (account_inactive, invalid_auth, etc.) during startup and reconnect instead of retry-looping indefinitely, including unable_to_socket_mode_start error payload propagation. (#32377) Thanks @scoootscooob.
  • -
  • Gateway/macOS LaunchAgent hardening: write Umask=077 in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
  • -
  • macOS/LaunchAgent security defaults: write Umask=63 (octal 077) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system 022. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
  • -
  • Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from HTTPS_PROXY/HTTP_PROXY env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
  • -
  • Sandbox/workspace mount permissions: make primary /workspace bind mounts read-only whenever workspaceAccess is not rw (including none) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
  • -
  • Tools/fsPolicy propagation: honor tools.fs.workspaceOnly for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
  • -
  • Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like node@22) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
  • -
  • Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
  • -
  • Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded /api/channels/* variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
  • -
  • Browser/Gateway hardening: preserve env credentials for OPENCLAW_GATEWAY_URL / CLAWDBOT_GATEWAY_URL while treating explicit --url as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.
  • -
  • Gateway/Control UI basePath webhook passthrough: let non-read methods under configured controlUiBasePath fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
  • -
  • Control UI/Legacy browser compatibility: replace toSorted-dependent cron suggestion sorting in app-render with a compatibility helper so older browsers without Array.prototype.toSorted no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
  • -
  • macOS/PeekabooBridge: add compatibility socket symlinks for legacy clawdbot, clawdis, and moltbot Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
  • -
  • Gateway/message tool reliability: avoid false Unknown channel failures when message.* actions receive platform-specific channel ids by falling back to toolContext.currentChannelProvider, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
  • -
  • Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for .cmd shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.
  • -
  • Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for sessions_spawn with runtime="acp" by rejecting ACP spawns from sandboxed requester sessions and rejecting sandbox="require" for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
  • -
  • Security/Web tools SSRF guard: keep DNS pinning for untrusted web_fetch and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
  • -
  • Gemini schema sanitization: coerce malformed JSON Schema properties values (null, arrays, primitives) to {} before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
  • -
  • Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
  • -
  • Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.
  • -
  • Browser/Extension relay stale tabs: evict stale cached targets from /json/list when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
  • -
  • Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up PortInUseError races after browser start/open. (#29538) Thanks @AaronWander.
  • -
  • OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty function_call_output.call_id payloads in the WS conversion path to avoid OpenAI 400 errors (Invalid 'input[n].call_id': empty string), with regression coverage for both inbound stream normalization and outbound payload guards.
  • -
  • Security/Nodes camera URL downloads: bind node camera.snap/camera.clip URL payload downloads to the resolved node host, enforce fail-closed behavior when node remoteIp is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
  • -
  • Config/backups hardening: enforce owner-only (0600) permissions on rotated config backups and clean orphan .bak.* files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
  • -
  • Telegram/inbound media filenames: preserve original file_name metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
  • -
  • Gateway/OpenAI chat completions: honor x-openclaw-message-channel when building agentCommand input for /v1/chat/completions, preserving caller channel identity instead of forcing webchat. (#30462) Thanks @bmendonca3.
  • -
  • Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
  • -
  • Media/MIME normalization: normalize parameterized/case-variant MIME strings in kindFromMime (for example Audio/Ogg; codecs=opus) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
  • -
  • Discord/audio preflight mentions: detect audio attachments via Discord content_type and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
  • -
  • Feishu/topic session routing: use thread_id as topic session scope fallback when root_id is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
  • -
  • Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of NO_REPLY and keep final-message buffering in sync, preventing partial NO leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
  • -
  • Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
  • -
  • Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.
  • -
  • Voice-call/Twilio external outbound: auto-register webhook-first outbound-api calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
  • -
  • Feishu/topic root replies: prefer root_id as outbound replyTargetMessageId when present, and parse millisecond message_create_time values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
  • -
  • Feishu/DM pairing reply target: send pairing challenge replies to chat: instead of user: so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
  • -
  • Feishu/Lark private DM routing: treat inbound chat_type: "private" as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
  • -
  • Signal/message actions: allow react to fall back to toolContext.currentMessageId when messageId is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
  • -
  • Discord/message actions: allow react to fall back to toolContext.currentMessageId when messageId is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
  • -
  • Synology Chat/reply delivery: resolve webhook usernames to Chat API user_id values for outbound chatbot replies, avoiding mismatches between webhook user IDs and method=chatbot recipient IDs in multi-account setups. (#23709) Thanks @druide67.
  • -
  • Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
  • -
  • Slack/session routing: keep top-level channel messages in one shared session when replyToMode=off, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.
  • -
  • Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
  • -
  • Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.
  • -
  • Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (monitor.account-scope.test.ts) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.
  • -
  • Feishu/Send target prefixes: normalize explicit group:/dm: send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.
  • -
  • Webchat/Feishu session continuation: preserve routable OriginatingChannel/OriginatingTo metadata from session delivery context in chat.send, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
  • -
  • Telegram/implicit mention forum handling: exclude Telegram forum system service messages (forum_topic_*, general_forum_topic_*) from reply-chain implicit mention detection so requireMention does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
  • -
  • Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
  • -
  • Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (provider: "message") and normalize lark/feishu provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
  • -
  • Webchat/silent token leak: filter assistant NO_REPLY-only transcript entries from chat.history responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
  • -
  • Doctor/local memory provider checks: stop false-positive local-provider warnings when provider=local and no explicit modelPath is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
  • -
  • Media understanding/parakeet CLI output parsing: read parakeet-mlx transcripts from --output-dir/.txt when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
  • -
  • Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
  • -
  • Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
  • -
  • Gateway/Node browser proxy routing: honor profile from browser.request JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.
  • -
  • Gateway/Control UI basePath POST handling: return 405 for POST on exact basePath routes (for example /openclaw) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.
  • -
  • Browser/default profile selection: default browser.defaultProfile behavior now prefers openclaw (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the chrome relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
  • -
  • Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.
  • -
  • Models/config env propagation: apply config.env.vars before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
  • -
  • Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so openclaw models status no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
  • -
  • Gateway/Heartbeat model reload: treat models.* and agents.defaults.model config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
  • -
  • Memory/LanceDB embeddings: forward configured embedding.dimensions into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
  • -
  • Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
  • -
  • Browser/CDP status accuracy: require a successful Browser.getVersion response over the CDP websocket (not just socket-open) before reporting cdpReady, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
  • -
  • Daemon/systemd checks in containers: treat missing systemctl invocations (including spawn systemctl ENOENT/EACCES) as unavailable service state during is-enabled checks, preventing container flows from failing with Gateway service check failed before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.
  • -
  • Security/Node exec approvals: revalidate approval-bound cwd identity immediately before execution/forwarding and fail closed with an explicit denial when cwd drifts after approval hardening.
  • -
  • Security audit/skills workspace hardening: add skills.workspace.symlink_escape warning in openclaw security audit when workspace skills/**/SKILL.md resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
  • -
  • Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example env sh -c ...) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
  • -
  • Security/fs-safe write hardening: make writeFileWithinRoot use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
  • -
  • Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
  • -
  • Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like [System Message] and line-leading System: in untrusted message content. (#30448)
  • -
  • Sandbox/Docker setup command parsing: accept agents.*.sandbox.docker.setupCommand as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
  • -
  • Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction AGENTS.md context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
  • -
  • Agents/Sandbox workdir mapping: map container workdir paths (for example /workspace) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
  • -
  • Docker/Sandbox bootstrap hardening: make OPENCLAW_SANDBOX opt-in parsing explicit (1|true|yes|on), support custom Docker socket paths via OPENCLAW_DOCKER_SOCKET, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to off when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
  • -
  • Hooks/webhook ACK compatibility: return 200 (instead of 202) for successful /hooks/agent requests so providers that require 200 (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
  • -
  • Feishu/Run channel fallback: prefer Provider over Surface when inferring queued run messageProvider fallback (when OriginatingChannel is missing), preventing Feishu turns from being mislabeled as webchat in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
  • -
  • Skills/sherpa-onnx-tts: run the sherpa-onnx-tts bin under ESM (replace CommonJS require imports) and add regression coverage to prevent require is not defined in ES module scope startup crashes. (#31965) Thanks @bmendonca3.
  • -
  • Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
  • -
  • Slack/Channel message subscriptions: register explicit message.channels and message.groups monitor handlers (alongside generic message) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.
  • -
  • Hooks/session-scoped memory context: expose ephemeral sessionId in embedded plugin tool contexts and before_tool_call/after_tool_call hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across /new and /reset. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
  • -
  • Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
  • -
  • Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
  • -
  • Feishu/File upload filenames: percent-encode non-ASCII/special-character file_name values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
  • -
  • Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized kindFromMime so mixed-case/parameterized MIME values classify consistently across message channels.
  • -
  • WhatsApp/inbound self-message context: propagate inbound fromMe through the web inbox pipeline and annotate direct self messages as (self) in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
  • -
  • Webchat/stream finalization: persist streamed assistant text when final events omit message, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
  • -
  • Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
  • -
  • Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
  • -
  • Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
  • -
  • Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)
  • -
  • Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured LarkApiError responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)
  • -
  • Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (contact:contact.base:readonly) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)
  • -
  • BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound message_id selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
  • -
  • WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
  • -
  • Feishu/default account resolution: always honor explicit channels.feishu.defaultAccount during outbound account selection (including top-level-credential setups where the preferred id is not present in accounts), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
  • -
  • Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (contact:contact.base:readonly) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
  • -
  • Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
  • -
  • Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
  • -
  • Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.
  • -
  • Browser/Extension re-announce reliability: keep relay state in connecting when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.
  • -
  • Browser/Act request compatibility: accept legacy flattened action="act" params (kind/ref/text/...) in addition to request={...} so browser act calls no longer fail with request required. (#15120) Thanks @vincentkoc.
  • -
  • OpenRouter/x-ai compatibility: skip reasoning.effort injection for x-ai/* models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
  • -
  • Models/openai-completions developer-role compatibility: force supportsDeveloperRole=false for non-native endpoints, treat unparseable baseUrl values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
  • -
  • Browser/Profile attach-only override: support browser.profiles..attachOnly (fallback to global browser.attachOnly) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.
  • -
  • Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file starttime with /proc//stat starttime, so stale .jsonl.lock files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.
  • -
  • Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel resolveDefaultTo fallback) when delivery.to is omitted. (#32364) Thanks @hclsys.
  • -
  • OpenAI media capabilities: include audio in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
  • -
  • Browser/Managed tab cap: limit loopback managed openclaw page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
  • -
  • Docker/Image health checks: add Dockerfile HEALTHCHECK that probes gateway GET /healthz so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
  • -
  • Gateway/Node dangerous-command parity: include sms.send in default onboarding node denyCommands, share onboarding deny defaults with the gateway dangerous-command source of truth, and include sms.send in phone-control /phone arm writes handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.
  • -
  • Pairing/AllowFrom account fallback: handle omitted accountId values in readChannelAllowFromStore and readChannelAllowFromStoreSync as default, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
  • -
  • Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.
  • -
  • Browser/CDP proxy bypass: force direct loopback agent paths and scoped NO_PROXY expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.
  • -
  • Sessions/idle reset correctness: preserve existing updatedAt during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
  • -
  • Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing starttime when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
  • -
  • Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (mtimeMs + sizeBytes), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.
  • -
  • Agents/Subagents sessions_spawn: reject malformed agentId inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
  • -
  • CLI/installer Node preflight: enforce Node.js v22.12+ consistently in both openclaw.mjs runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
  • -
  • Web UI/config form: support SecretInput string-or-secret-ref unions in map additionalProperties, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
  • -
  • Auto-reply/inline command cleanup: preserve newline structure when stripping inline /status and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
  • -
  • Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like source/provider), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
  • -
  • Hooks/runtime stability: keep the internal hook handler registry on a globalThis singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
  • -
  • Hooks/after_tool_call: include embedded session context (sessionKey, agentId) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
  • -
  • Hooks/tool-call correlation: include runId and toolCallId in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in before_tool_call and after_tool_call. (#32360) Thanks @vincentkoc.
  • -
  • Plugins/install diagnostics: reject legacy plugin package shapes without openclaw.extensions and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.
  • -
  • Hooks/plugin context parity: ensure llm_input hooks in embedded attempts receive the same trigger and channelId-aware hookCtx used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
  • -
  • Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (pnpm, bun) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.
  • -
  • Cron/session reaper reliability: move cron session reaper sweeps into onTimer finally and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
  • -
  • Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so HEARTBEAT_OK noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
  • -
  • Authentication: classify permission_error as auth_permanent for profile fallback. (#31324) Thanks @Sid-Qin.
  • -
  • Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (newText present and oldText absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.
  • -
  • Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example diffs -> bundled @openclaw/diffs), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.
  • -
  • Web UI/inline code copy fidelity: disable forced mid-token wraps on inline spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
  • -
  • Restart sentinel formatting: avoid duplicate Reason: lines when restart message text already matches stats.reason, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
  • -
  • Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
  • -
  • Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
  • -
  • Failover/error classification: treat HTTP 529 (provider overloaded, common with Anthropic-compatible APIs) as rate_limit so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
  • -
  • Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.
  • -
  • Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
  • -
  • Secrets/exec resolver timeout defaults: use provider timeoutMs as the default inactivity (noOutputTimeoutMs) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
  • -
  • Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
  • -
  • Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing HEARTBEAT_OK from being delivered to users. (#32131) Thanks @adhishthite.
  • -
  • Cron/store migration: normalize legacy cron jobs with string schedule and top-level command/timeout fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
  • -
  • Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
  • -
  • Tests/Subagent announce: set OPENCLAW_TEST_FAST=1 before importing subagent-announce format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
  • -
-

View full changelog

-]]>
- - -
\ No newline at end of file diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index a7ffff29062..b187e131048 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -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") diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 5391ff78fe7..448336d8e41 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -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( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index dc33bdb6836..db550ded615 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -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 ") 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) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt index a3f7868fa90..c7cdf8289ff 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsSheet.kt @@ -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)) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt index be66f42bef3..f8e17a17c6b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/VoiceTabScreen.kt @@ -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 = diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt index 9601febfa31..25fafe95073 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatComposer.kt @@ -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), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt index 9d08352a3f0..f61195f43fb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageViews.kt @@ -151,7 +151,7 @@ fun ChatPendingToolsBubble(toolCalls: List) { 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) { 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" } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt index 2c09f4488b0..e20b57ac3f5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatSheetContent.kt @@ -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, 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), - ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt index eff52017624..7ada19e166b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/voice/TalkModeVoiceResolver.kt @@ -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() } } } diff --git a/apps/ios/README.md b/apps/ios/README.md index 0e78d8cf0d9..8e591839bd0 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -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: diff --git a/apps/ios/ShareExtension/ShareViewController.swift b/apps/ios/ShareExtension/ShareViewController.swift index 1181641e330..00f1b06f9dc 100644 --- a/apps/ios/ShareExtension/ShareViewController.swift +++ b/apps/ios/ShareExtension/ShareViewController.swift @@ -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, diff --git a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift index 9822ac1706f..dc2859d86d9 100644 --- a/apps/ios/Sources/Onboarding/OnboardingStateStore.swift +++ b/apps/ios/Sources/Onboarding/OnboardingStateStore.swift @@ -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) ?? "" diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 060b398eba4..516e7b373eb 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -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? @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." } } diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 3dec2fa779b..6df8c1ec510 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -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 diff --git a/apps/ios/Tests/OnboardingStateStoreTests.swift b/apps/ios/Tests/OnboardingStateStoreTests.swift index 30c014647b6..06a6a0f3ec2 100644 --- a/apps/ios/Tests/OnboardingStateStoreTests.swift +++ b/apps/ios/Tests/OnboardingStateStoreTests.swift @@ -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 diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index fb32b1e907b..74cbcec4b68 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -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 diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 6c9398474ca..218d638a7e5 100644 --- a/apps/macos/Sources/OpenClaw/Resources/Info.plist +++ b/apps/macos/Sources/OpenClaw/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.3.12 + 2026.3.13 CFBundleVersion - 202603120 + 202603130 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/changelog/fragments/openai-codex-auth-tests-gpt54.md b/changelog/fragments/openai-codex-auth-tests-gpt54.md new file mode 100644 index 00000000000..ec1cd4b199f --- /dev/null +++ b/changelog/fragments/openai-codex-auth-tests-gpt54.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index cc7169d3a88..c0bffc64458 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker-setup.sh b/docker-setup.sh index 450c2025ffa..19e5461765b 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -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" diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 09693589af7..bc9d435f4de 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -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::googlechat:dm:`. + - DMs use session key `agent::googlechat:direct:`. - Spaces use session key `agent::googlechat:group:`. 4. DM access is pairing by default. Unknown senders receive a pairing code; approve with: - `openclaw pairing approve googlechat ` diff --git a/docs/channels/signal.md b/docs/channels/signal.md index b216af120ce..cfc050b6e75 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -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["" | "*"]` can override group behavior with `requireMention`, `tools`, and `toolsBySender`. +- Use `channels.signal.accounts..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:`). `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..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[""].historyLimit`. - `channels.signal.textChunkLimit`: outbound chunk size (chars). diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index b8c1ebfac6f..6ea2df094f0 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -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 ``` diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 5c60655858e..2a58c15cb4d 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -191,9 +191,9 @@ the workspace is writable. See [Memory](/concepts/memory) and - Direct chats follow `session.dmScope` (default `main`). - `main`: `agent::` (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::dm:`. - - `per-channel-peer`: `agent:::dm:`. - - `per-account-channel-peer`: `agent::::dm:` (accountId defaults to `default`). + - `per-peer`: `agent::direct:`. + - `per-channel-peer`: `agent:::direct:`. + - `per-account-channel-peer`: `agent::::direct:` (accountId defaults to `default`). - If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `` so the same person shares a session across channels. - Group chats isolate state: `agent:::group:` (rooms/channels use `agent:::channel:`). - Telegram forum topics append `:topic:` to the group id for isolation. diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 1a5edfcc6e3..a1d1b482fb2 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -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 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index d7e5f5c25d3..0f1dd65cbbc 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -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": "", - "sessionKey": "agent:main:whatsapp:dm:+15555550123" + "sessionKey": "agent:main:whatsapp:direct:+15555550123" }' ``` diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index d1266c24830..5276d46848e 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -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: ()" \ 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 "" --team-id "" --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: ()" \ 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. diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index e755a241375..e40d798604d 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -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: diff --git a/docs/reference/token-use.md b/docs/reference/token-use.md index 9e85c25e687..8493e99f098 100644 --- a/docs/reference/token-use.md +++ b/docs/reference/token-use.md @@ -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) diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 0bca1dee488..830dfa6f159 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 7dd6a045c15..5455bb2b38d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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..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 diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 95d39a46a49..66780c709b1 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -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": { diff --git a/extensions/acpx/src/runtime-internals/events.ts b/extensions/acpx/src/runtime-internals/events.ts index f83f4ddabb9..f0326bbe938 100644 --- a/extensions/acpx/src/runtime-internals/events.ts +++ b/extensions/acpx/src/runtime-internals/events.ts @@ -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; + 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, diff --git a/extensions/acpx/src/runtime.ts b/extensions/acpx/src/runtime.ts index b0f166584d5..e55ef360424 100644 --- a/extensions/acpx/src/runtime.ts +++ b/extensions/acpx/src/runtime.ts @@ -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; + } + | { + ok: false; + failure: + | { + kind: "version-check"; + versionCheck: Extract; + } + | { + kind: "help-check"; + result: Awaited>; + } + | { + 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 { - const versionCheck = await checkAcpxVersion({ + private async checkVersion(): Promise { + 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>> { + return await spawnAndCollect( + { + command: this.config.command, + args: ["--help"], + cwd: this.config.cwd, + stripProviderAuthEnvVars: this.config.stripProviderAuthEnvVars, + }, + this.spawnCommandOptions, + ); + } + + private async checkHealth(): Promise { + 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 { + const result = await this.checkHealth(); + this.healthy = result.ok; + } + async ensureSession(input: AcpRuntimeEnsureInput): Promise { const sessionName = asTrimmedString(input.sessionKey); if (!sessionName) { @@ -494,14 +552,9 @@ export class AcpxRuntime implements AcpRuntime { } async doctor(): Promise { - 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 { diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index e60dc2ea639..b2c13701ead 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 2b902e216db..9829860d042 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -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", diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index ed34f16faf9..95eea6a702a 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index 8e84cfb45c3..bb5f232517a 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.12", + "version": "2026.3.13", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 0f8c0635e9c..337e6fd90a5 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index 3c31e647553..d44131fa4cf 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -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": { diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 4aba038b4a9..41438c570f2 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -22,6 +22,45 @@ export type DownloadMessageResourceResult = { fileName?: string; }; +function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): { + account: ReturnType; + client: ReturnType; +} { + 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 { 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 { 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", + }), + }; } /** diff --git a/extensions/feishu/src/reactions.ts b/extensions/feishu/src/reactions.ts index d446a674b88..951b3d03c6b 100644 --- a/extensions/feishu/src/reactions.ts +++ b/extensions/feishu/src/reactions.ts @@ -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 { 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 { 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) => ({ diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index 0f4fd7e7758..5bfa836e0a6 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -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 { + 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 { + 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"); + }); +}); diff --git a/extensions/msteams/src/graph-upload.ts b/extensions/msteams/src/graph-upload.ts index 65e854ac439..9705b1a63a4 100644 --- a/extensions/msteams/src/graph-upload.ts +++ b/extensions/msteams/src/graph-upload.ts @@ -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 { + 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 { - 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 { - 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 { diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json index 24231f71cea..6c7957a5b25 100644 --- a/extensions/nextcloud-talk/package.json +++ b/extensions/nextcloud-talk/package.json @@ -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": { diff --git a/extensions/nextcloud-talk/src/normalize.test.ts b/extensions/nextcloud-talk/src/normalize.test.ts new file mode 100644 index 00000000000..2419e063ff1 --- /dev/null +++ b/extensions/nextcloud-talk/src/normalize.test.ts @@ -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); + }); +}); diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts index 6854d603fc0..295caadd8a4 100644 --- a/extensions/nextcloud-talk/src/normalize.ts +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -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 { diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 7cc8f05658c..2b6284a6fc2 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -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 { +function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): { + cfg: CoreConfig; + account: ReturnType; + 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 { + 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 = {}, ): 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 }); diff --git a/extensions/nostr/CHANGELOG.md b/extensions/nostr/CHANGELOG.md index 697a4423f96..0e59b1cb08e 100644 --- a/extensions/nostr/CHANGELOG.md +++ b/extensions/nostr/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.13 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.12 ### Changes diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index bffacd76e07..1c3499f3481 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -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": { diff --git a/extensions/ollama/package.json b/extensions/ollama/package.json index 766687aa1e5..5bdf5fd688e 100644 --- a/extensions/ollama/package.json +++ b/extensions/ollama/package.json @@ -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", diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json index a1570f96f66..f8f0e97cef3 100644 --- a/extensions/open-prose/package.json +++ b/extensions/open-prose/package.json @@ -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", diff --git a/extensions/sglang/package.json b/extensions/sglang/package.json index 6ee92946db7..6b38cfafb60 100644 --- a/extensions/sglang/package.json +++ b/extensions/sglang/package.json @@ -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", diff --git a/extensions/signal/package.json b/extensions/signal/package.json index ecb862d4364..95a4879cc82 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/signal", - "version": "2026.3.12", + "version": "2026.3.13", "private": true, "description": "OpenClaw Signal channel plugin", "type": "module", diff --git a/extensions/slack/package.json b/extensions/slack/package.json index a166c432a36..6fbcfb6f122 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/slack", - "version": "2026.3.12", + "version": "2026.3.13", "private": true, "description": "OpenClaw Slack channel plugin", "type": "module", diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index bf2653078ec..bc8623b6059 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -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": { diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index bc00f6c016c..2b4e5fd584d 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/telegram", - "version": "2026.3.12", + "version": "2026.3.13", "private": true, "description": "OpenClaw Telegram channel plugin", "type": "module", diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index abf1d2745c6..e5f9c1e9ed5 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/tlon", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw Tlon/Urbit channel plugin", "type": "module", "dependencies": { diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index c0649dfbe85..3eccbf6cbc9 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -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(""); } diff --git a/extensions/tlon/src/urbit/channel-ops.ts b/extensions/tlon/src/urbit/channel-ops.ts index f5401d3bb73..ef65e4ca9fe 100644 --- a/extensions/tlon/src/urbit/channel-ops.ts +++ b/extensions/tlon/src/urbit/channel-ops.ts @@ -12,6 +12,29 @@ export type UrbitChannelDeps = { fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; }; +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 { - 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 { - 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", }); diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index ca95a0412d4..1a573a6b359 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -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>; + + 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( diff --git a/extensions/twitch/CHANGELOG.md b/extensions/twitch/CHANGELOG.md index 547069ba8ba..123b391c2ce 100644 --- a/extensions/twitch/CHANGELOG.md +++ b/extensions/twitch/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.13 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.12 ### Changes diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index a39b7b6e3d0..5213b5c7b74 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/twitch", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw Twitch channel plugin", "type": "module", "dependencies": { diff --git a/extensions/vllm/package.json b/extensions/vllm/package.json index 493486551ea..3ef665a6bf2 100644 --- a/extensions/vllm/package.json +++ b/extensions/vllm/package.json @@ -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", diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index 82a8e02a623..25b90b3db54 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.13 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.12 ### Changes diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index 65012d94a66..75c500db1f9 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/voice-call", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw voice-call plugin", "type": "module", "dependencies": { diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index 98e4b646852..383edd4612d 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/whatsapp", - "version": "2026.3.12", + "version": "2026.3.13", "private": true, "description": "OpenClaw WhatsApp channel plugin", "type": "module", diff --git a/extensions/zalo/CHANGELOG.md b/extensions/zalo/CHANGELOG.md index 14de72d85f3..154f69b9867 100644 --- a/extensions/zalo/CHANGELOG.md +++ b/extensions/zalo/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.13 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.12 ### Changes diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 285246486fb..3a9f118a4f6 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalo", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw Zalo channel plugin", "type": "module", "dependencies": { diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md index b503c283a39..09dfdbb1ff3 100644 --- a/extensions/zalouser/CHANGELOG.md +++ b/extensions/zalouser/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2026.3.13 + +### Changes + +- Version alignment with core OpenClaw release numbers. + ## 2026.3.12 ### Changes diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 5046deabca0..82e796cf676 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/zalouser", - "version": "2026.3.12", + "version": "2026.3.13", "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { diff --git a/package.json b/package.json index a5b9beb32a3..c63e72f66fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw", - "version": "2026.3.12", + "version": "2026.3.13", "description": "Multi-channel AI gateway with extensible messaging integrations", "keywords": [], "homepage": "https://github.com/openclaw/openclaw#readme", @@ -405,6 +405,7 @@ "@typescript/native-preview": "7.0.0-dev.20260312.1", "@vitest/coverage-v8": "^4.1.0", "jscpd": "4.0.8", + "jsdom": "^28.1.0", "lit": "^3.3.2", "oxfmt": "0.40.0", "oxlint": "^1.55.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8448ab29a5..ac32d145c57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: jscpd: specifier: 4.0.8 version: 4.0.8 + jsdom: + specifier: ^28.1.0 + version: 28.1.0(@noble/hashes@2.0.1) lit: specifier: ^3.3.2 version: 3.3.2 diff --git a/scripts/ios-write-version-xcconfig.sh b/scripts/ios-write-version-xcconfig.sh index 4c39885d1dd..e38044814bf 100755 --- a/scripts/ios-write-version-xcconfig.sh +++ b/scripts/ios-write-version-xcconfig.sh @@ -73,7 +73,7 @@ fi if [[ "${PACKAGE_VERSION}" =~ ^([0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2})([.-]?beta[.-][0-9]+)?$ ]]; then MARKETING_VERSION="${BASH_REMATCH[1]}" else - echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.12 or 2026.3.12-beta.1." >&2 + echo "Unsupported package.json.version '${PACKAGE_VERSION}'. Expected 2026.3.13 or 2026.3.13-beta.1." >&2 exit 1 fi diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index fc52ee2205e..56b9c16203c 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -251,7 +251,7 @@ describe("normalizeModelCompat", () => { }); }); - it("overrides explicit supportsDeveloperRole true on non-native endpoints", () => { + it("respects explicit supportsDeveloperRole true on non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -259,10 +259,10 @@ describe("normalizeModelCompat", () => { compat: { supportsDeveloperRole: true }, }; const normalized = normalizeModelCompat(model); - expect(supportsDeveloperRole(normalized)).toBe(false); + expect(supportsDeveloperRole(normalized)).toBe(true); }); - it("overrides explicit supportsUsageInStreaming true on non-native endpoints", () => { + it("respects explicit supportsUsageInStreaming true on non-native endpoints", () => { const model = { ...baseModel(), provider: "custom-cpa", @@ -270,6 +270,18 @@ describe("normalizeModelCompat", () => { compat: { supportsUsageInStreaming: true }, }; const normalized = normalizeModelCompat(model); + expect(supportsUsageInStreaming(normalized)).toBe(true); + }); + + it("still forces flags off when not explicitly set by user", () => { + const model = { + ...baseModel(), + provider: "custom-cpa", + baseUrl: "https://proxy.example.com/v1", + }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model); + expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsUsageInStreaming(normalized)).toBe(false); }); diff --git a/src/agents/model-compat.ts b/src/agents/model-compat.ts index 7bad084fe57..72deb0c655f 100644 --- a/src/agents/model-compat.ts +++ b/src/agents/model-compat.ts @@ -55,17 +55,22 @@ export function normalizeModelCompat(model: Model): Model { // The `developer` role and stream usage chunks are OpenAI-native behaviors. // Many OpenAI-compatible backends reject `developer` and/or emit usage-only // chunks that break strict parsers expecting choices[0]. For non-native - // openai-completions endpoints, force both compat flags off. + // openai-completions endpoints, force both compat flags off β€” unless the + // user has explicitly opted in via their model config. const compat = model.compat ?? undefined; // When baseUrl is empty the pi-ai library defaults to api.openai.com, so // leave compat unchanged and let default native behavior apply. - // Note: explicit true values are intentionally overridden for non-native - // endpoints for safety. const needsForce = baseUrl ? !isOpenAINativeEndpoint(baseUrl) : false; if (!needsForce) { return model; } - if (compat?.supportsDeveloperRole === false && compat?.supportsUsageInStreaming === false) { + + // Respect explicit user overrides: if the user has set a compat flag to + // true in their model definition, they know their endpoint supports it. + const forcedDeveloperRole = compat?.supportsDeveloperRole === true; + const forcedUsageStreaming = compat?.supportsUsageInStreaming === true; + + if (forcedDeveloperRole && forcedUsageStreaming) { return model; } @@ -73,7 +78,11 @@ export function normalizeModelCompat(model: Model): Model { return { ...model, compat: compat - ? { ...compat, supportsDeveloperRole: false, supportsUsageInStreaming: false } + ? { + ...compat, + supportsDeveloperRole: forcedDeveloperRole || false, + supportsUsageInStreaming: forcedUsageStreaming || false, + } : { supportsDeveloperRole: false, supportsUsageInStreaming: false }, } as typeof model; } diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 63aef63561c..35ac52dcf26 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -80,131 +80,121 @@ describe("model-selection", () => { }); describe("parseModelRef", () => { - it("should parse full model refs", () => { - expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({ - provider: "anthropic", - model: "claude-3-5-sonnet", - }); + const expectParsedModelVariants = ( + variants: string[], + defaultProvider: string, + expected: { provider: string; model: string }, + ) => { + for (const raw of variants) { + expect(parseModelRef(raw, defaultProvider), raw).toEqual(expected); + } + }; + + it.each([ + { + name: "parses explicit provider/model refs", + variants: ["anthropic/claude-3-5-sonnet"], + defaultProvider: "openai", + expected: { provider: "anthropic", model: "claude-3-5-sonnet" }, + }, + { + name: "uses the default provider when omitted", + variants: ["claude-3-5-sonnet"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-3-5-sonnet" }, + }, + { + name: "preserves nested model ids after the provider prefix", + variants: ["nvidia/moonshotai/kimi-k2.5"], + defaultProvider: "anthropic", + expected: { provider: "nvidia", model: "moonshotai/kimi-k2.5" }, + }, + { + name: "normalizes anthropic shorthand aliases", + variants: ["anthropic/opus-4.6", "opus-4.6", " anthropic / opus-4.6 "], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-opus-4-6" }, + }, + { + name: "normalizes anthropic sonnet aliases", + variants: ["anthropic/sonnet-4.6", "sonnet-4.6"], + defaultProvider: "anthropic", + expected: { provider: "anthropic", model: "claude-sonnet-4-6" }, + }, + { + name: "normalizes deprecated google flash preview ids", + variants: ["google/gemini-3.1-flash-preview", "gemini-3.1-flash-preview"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3-flash-preview" }, + }, + { + name: "normalizes gemini 3.1 flash-lite ids", + variants: ["google/gemini-3.1-flash-lite", "gemini-3.1-flash-lite"], + defaultProvider: "google", + expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" }, + }, + { + name: "keeps OpenAI codex refs on the openai provider", + variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"], + defaultProvider: "openai", + expected: { provider: "openai", model: "gpt-5.3-codex" }, + }, + { + name: "preserves openrouter native model prefixes", + variants: ["openrouter/aurora-alpha"], + defaultProvider: "openai", + expected: { provider: "openrouter", model: "openrouter/aurora-alpha" }, + }, + { + name: "passes through openrouter upstream provider ids", + variants: ["openrouter/anthropic/claude-sonnet-4-5"], + defaultProvider: "openai", + expected: { provider: "openrouter", model: "anthropic/claude-sonnet-4-5" }, + }, + { + name: "normalizes Vercel Claude shorthand to anthropic-prefixed model ids", + variants: ["vercel-ai-gateway/claude-opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }, + { + name: "normalizes Vercel Anthropic aliases without double-prefixing", + variants: ["vercel-ai-gateway/opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4-6" }, + }, + { + name: "keeps already-prefixed Vercel Anthropic models unchanged", + variants: ["vercel-ai-gateway/anthropic/claude-opus-4.6"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "anthropic/claude-opus-4.6" }, + }, + { + name: "passes through non-Claude Vercel model ids unchanged", + variants: ["vercel-ai-gateway/openai/gpt-5.2"], + defaultProvider: "openai", + expected: { provider: "vercel-ai-gateway", model: "openai/gpt-5.2" }, + }, + { + name: "keeps already-suffixed codex variants unchanged", + variants: ["openai/gpt-5.3-codex-codex"], + defaultProvider: "anthropic", + expected: { provider: "openai", model: "gpt-5.3-codex-codex" }, + }, + ])("$name", ({ variants, defaultProvider, expected }) => { + expectParsedModelVariants(variants, defaultProvider, expected); }); - it("preserves nested model ids after provider prefix", () => { - expect(parseModelRef("nvidia/moonshotai/kimi-k2.5", "anthropic")).toEqual({ - provider: "nvidia", - model: "moonshotai/kimi-k2.5", - }); + it("round-trips normalized refs through modelKey", () => { + const parsed = parseModelRef(" opus-4.6 ", "anthropic"); + expect(parsed).toEqual({ provider: "anthropic", model: "claude-opus-4-6" }); + expect(modelKey(parsed?.provider ?? "", parsed?.model ?? "")).toBe( + "anthropic/claude-opus-4-6", + ); }); - it("normalizes anthropic alias refs to canonical model ids", () => { - expect(parseModelRef("anthropic/opus-4.6", "openai")).toEqual({ - provider: "anthropic", - model: "claude-opus-4-6", - }); - expect(parseModelRef("opus-4.6", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-opus-4-6", - }); - expect(parseModelRef("anthropic/sonnet-4.6", "openai")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }); - expect(parseModelRef("sonnet-4.6", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-4-6", - }); - }); - - it("should use default provider if none specified", () => { - expect(parseModelRef("claude-3-5-sonnet", "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-3-5-sonnet", - }); - }); - - it("normalizes deprecated google flash preview ids to the working model id", () => { - expect(parseModelRef("google/gemini-3.1-flash-preview", "openai")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - expect(parseModelRef("gemini-3.1-flash-preview", "google")).toEqual({ - provider: "google", - model: "gemini-3-flash-preview", - }); - }); - - it("normalizes gemini 3.1 flash-lite to the preview model id", () => { - expect(parseModelRef("google/gemini-3.1-flash-lite", "openai")).toEqual({ - provider: "google", - model: "gemini-3.1-flash-lite-preview", - }); - expect(parseModelRef("gemini-3.1-flash-lite", "google")).toEqual({ - provider: "google", - model: "gemini-3.1-flash-lite-preview", - }); - }); - - it("keeps openai gpt-5.3 codex refs on the openai provider", () => { - expect(parseModelRef("openai/gpt-5.3-codex", "anthropic")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex", - }); - expect(parseModelRef("gpt-5.3-codex", "openai")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex", - }); - expect(parseModelRef("openai/gpt-5.3-codex-codex", "anthropic")).toEqual({ - provider: "openai", - model: "gpt-5.3-codex-codex", - }); - }); - - it("should return null for empty strings", () => { - expect(parseModelRef("", "anthropic")).toBeNull(); - expect(parseModelRef(" ", "anthropic")).toBeNull(); - }); - - it("should preserve openrouter/ prefix for native models", () => { - expect(parseModelRef("openrouter/aurora-alpha", "openai")).toEqual({ - provider: "openrouter", - model: "openrouter/aurora-alpha", - }); - }); - - it("should pass through openrouter external provider models as-is", () => { - expect(parseModelRef("openrouter/anthropic/claude-sonnet-4-5", "openai")).toEqual({ - provider: "openrouter", - model: "anthropic/claude-sonnet-4-5", - }); - }); - - it("normalizes Vercel Claude shorthand to anthropic-prefixed model ids", () => { - expect(parseModelRef("vercel-ai-gateway/claude-opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4.6", - }); - expect(parseModelRef("vercel-ai-gateway/opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4-6", - }); - }); - - it("keeps already-prefixed Vercel Anthropic models unchanged", () => { - expect(parseModelRef("vercel-ai-gateway/anthropic/claude-opus-4.6", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "anthropic/claude-opus-4.6", - }); - }); - - it("passes through non-Claude Vercel model ids unchanged", () => { - expect(parseModelRef("vercel-ai-gateway/openai/gpt-5.2", "openai")).toEqual({ - provider: "vercel-ai-gateway", - model: "openai/gpt-5.2", - }); - }); - - it("should handle invalid slash usage", () => { - expect(parseModelRef("/", "anthropic")).toBeNull(); - expect(parseModelRef("anthropic/", "anthropic")).toBeNull(); - expect(parseModelRef("/model", "anthropic")).toBeNull(); + it.each(["", " ", "/", "anthropic/", "/model"])("returns null for invalid ref %j", (raw) => { + expect(parseModelRef(raw, "anthropic")).toBeNull(); }); }); diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index 2af5e490c7f..241c7a0f858 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -106,7 +106,7 @@ describe("buildAssistantMessage", () => { expect(result.usage.totalTokens).toBe(15); }); - it("falls back to thinking when content is empty", () => { + it("drops thinking-only output when content is empty", () => { const response = { model: "qwen3:32b", created_at: "2026-01-01T00:00:00Z", @@ -119,10 +119,10 @@ describe("buildAssistantMessage", () => { }; const result = buildAssistantMessage(response, modelInfo); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Thinking output" }]); + expect(result.content).toEqual([]); }); - it("falls back to reasoning when content and thinking are empty", () => { + it("drops reasoning-only output when content and thinking are empty", () => { const response = { model: "qwen3:32b", created_at: "2026-01-01T00:00:00Z", @@ -135,7 +135,7 @@ describe("buildAssistantMessage", () => { }; const result = buildAssistantMessage(response, modelInfo); expect(result.stopReason).toBe("stop"); - expect(result.content).toEqual([{ type: "text", text: "Reasoning output" }]); + expect(result.content).toEqual([]); }); it("builds response with tool calls", () => { @@ -485,7 +485,7 @@ describe("createOllamaStreamFn", () => { ); }); - it("accumulates thinking chunks when content is empty", async () => { + it("drops thinking chunks when no final content is emitted", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","thinking":"reasoned"},"done":false}', @@ -501,7 +501,7 @@ describe("createOllamaStreamFn", () => { throw new Error("Expected done event"); } - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + expect(doneEvent.message.content).toEqual([]); }, ); }); @@ -528,7 +528,7 @@ describe("createOllamaStreamFn", () => { ); }); - it("accumulates reasoning chunks when thinking is absent", async () => { + it("drops reasoning chunks when no final content is emitted", async () => { await withMockNdjsonFetch( [ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}', @@ -544,7 +544,7 @@ describe("createOllamaStreamFn", () => { throw new Error("Expected done event"); } - expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]); + expect(doneEvent.message.content).toEqual([]); }, ); }); diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 9d23852bb31..70a2ef33cf1 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -340,10 +340,9 @@ export function buildAssistantMessage( ): AssistantMessage { const content: (TextContent | ToolCall)[] = []; - // Ollama-native reasoning models may emit their answer in `thinking` or - // `reasoning` with an empty `content`. Fall back so replies are not dropped. - const text = - response.message.content || response.message.thinking || response.message.reasoning || ""; + // Native Ollama reasoning fields are internal model output. The reply text + // must come from `content`; reasoning visibility is controlled elsewhere. + const text = response.message.content || ""; if (text) { content.push({ type: "text", text }); } @@ -497,20 +496,12 @@ export function createOllamaStreamFn( const reader = response.body.getReader(); let accumulatedContent = ""; - let fallbackContent = ""; - let sawContent = false; const accumulatedToolCalls: OllamaToolCall[] = []; let finalResponse: OllamaChatResponse | undefined; for await (const chunk of parseNdjsonStream(reader)) { if (chunk.message?.content) { - sawContent = true; accumulatedContent += chunk.message.content; - } else if (!sawContent && chunk.message?.thinking) { - fallbackContent += chunk.message.thinking; - } else if (!sawContent && chunk.message?.reasoning) { - // Backward compatibility for older/native variants that still use reasoning. - fallbackContent += chunk.message.reasoning; } // Ollama sends tool_calls in intermediate (done:false) chunks, @@ -529,7 +520,7 @@ export function createOllamaStreamFn( throw new Error("Ollama API stream ended without a final response"); } - finalResponse.message.content = accumulatedContent || fallbackContent; + finalResponse.message.content = accumulatedContent; if (accumulatedToolCalls.length > 0) { finalResponse.message.tool_calls = accumulatedToolCalls; } diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index 3cbefadbce8..e8578c7feb2 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -45,98 +45,117 @@ const GROQ_TOO_MANY_REQUESTS_MESSAGE = const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret +function expectMessageMatches( + matcher: (message: string) => boolean, + samples: readonly string[], + expected: boolean, +) { + for (const sample of samples) { + expect(matcher(sample), sample).toBe(expected); + } +} + describe("isAuthPermanentErrorMessage", () => { - it("matches permanent auth failure patterns", () => { - const samples = [ - "invalid_api_key", - "api key revoked", - "api key deactivated", - "key has been disabled", - "key has been revoked", - "account has been deactivated", - "could not authenticate api key", - "could not validate credentials", - "API_KEY_REVOKED", - "api_key_deleted", - ]; - for (const sample of samples) { - expect(isAuthPermanentErrorMessage(sample)).toBe(true); - } - }); - it("does not match transient auth errors", () => { - const samples = [ - "unauthorized", - "invalid token", - "authentication failed", - "forbidden", - "access denied", - "token has expired", - ]; - for (const sample of samples) { - expect(isAuthPermanentErrorMessage(sample)).toBe(false); - } + it.each([ + { + name: "matches permanent auth failure patterns", + samples: [ + "invalid_api_key", + "api key revoked", + "api key deactivated", + "key has been disabled", + "key has been revoked", + "account has been deactivated", + "could not authenticate api key", + "could not validate credentials", + "API_KEY_REVOKED", + "api_key_deleted", + ], + expected: true, + }, + { + name: "does not match transient auth errors", + samples: [ + "unauthorized", + "invalid token", + "authentication failed", + "forbidden", + "access denied", + "token has expired", + ], + expected: false, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isAuthPermanentErrorMessage, samples, expected); }); }); describe("isAuthErrorMessage", () => { - it("matches credential validation errors", () => { - const samples = [ - 'No credentials found for profile "anthropic:default".', - "No API key found for profile openai.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } - }); - it("matches OAuth refresh failures", () => { - const samples = [ - "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", - "Please re-authenticate to continue.", - ]; - for (const sample of samples) { - expect(isAuthErrorMessage(sample)).toBe(true); - } + it.each([ + 'No credentials found for profile "anthropic:default".', + "No API key found for profile openai.", + "OAuth token refresh failed for anthropic: Failed to refresh OAuth token for anthropic. Please try again or re-authenticate.", + "Please re-authenticate to continue.", + ])("matches auth errors for %j", (sample) => { + expect(isAuthErrorMessage(sample)).toBe(true); }); }); describe("isBillingErrorMessage", () => { - it("matches credit / payment failures", () => { - const samples = [ - "Your credit balance is too low to access the Anthropic API.", - "insufficient credits", - "Payment Required", - "HTTP 402 Payment Required", - "plans & billing", - // Venice returns "Insufficient USD or Diem balance" which has extra words - // between "insufficient" and "balance" - "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", - // OpenRouter returns "requires more credits" for underfunded accounts - "This model requires more credits to use", - "This endpoint require more credits", - ]; - for (const sample of samples) { - expect(isBillingErrorMessage(sample)).toBe(true); - } - }); - it("does not false-positive on issue IDs or text containing 402", () => { - const falsePositives = [ - "Fixed issue CHE-402 in the latest release", - "See ticket #402 for details", - "ISSUE-402 has been resolved", - "Room 402 is available", - "Error code 403 was returned, not 402-related", - "The building at 402 Main Street", - "processed 402 records", - "402 items found in the database", - "port 402 is open", - "Use a 402 stainless bolt", - "Book a 402 room", - "There is a 402 near me", - ]; - for (const sample of falsePositives) { - expect(isBillingErrorMessage(sample)).toBe(false); - } + it.each([ + { + name: "matches credit and payment failures", + samples: [ + "Your credit balance is too low to access the Anthropic API.", + "insufficient credits", + "Payment Required", + "HTTP 402 Payment Required", + "plans & billing", + "Insufficient USD or Diem balance to complete request. Visit https://venice.ai/settings/api to add credits.", + "This model requires more credits to use", + "This endpoint require more credits", + ], + expected: true, + }, + { + name: "does not false-positive on issue ids and numeric references", + samples: [ + "Fixed issue CHE-402 in the latest release", + "See ticket #402 for details", + "ISSUE-402 has been resolved", + "Room 402 is available", + "Error code 403 was returned, not 402-related", + "The building at 402 Main Street", + "processed 402 records", + "402 items found in the database", + "port 402 is open", + "Use a 402 stainless bolt", + "Book a 402 room", + "There is a 402 near me", + ], + expected: false, + }, + { + name: "still matches real HTTP 402 billing errors", + samples: [ + "HTTP 402 Payment Required", + "status: 402", + "error code 402", + "http 402", + "status=402 payment required", + "got a 402 from the API", + "returned 402", + "received a 402 response", + '{"status":402,"type":"error"}', + '{"code":402,"message":"payment required"}', + '{"error":{"code":402,"message":"billing hard limit reached"}}', + ], + expected: true, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isBillingErrorMessage, samples, expected); }); + it("does not false-positive on long assistant responses mentioning billing keywords", () => { // Simulate a multi-paragraph assistant response that mentions billing terms const longResponse = @@ -176,37 +195,27 @@ describe("isBillingErrorMessage", () => { expect(longNonError.length).toBeGreaterThan(512); expect(isBillingErrorMessage(longNonError)).toBe(false); }); - it("still matches real HTTP 402 billing errors", () => { - const realErrors = [ - "HTTP 402 Payment Required", - "status: 402", - "error code 402", - "http 402", - "status=402 payment required", - "got a 402 from the API", - "returned 402", - "received a 402 response", - '{"status":402,"type":"error"}', - '{"code":402,"message":"payment required"}', - '{"error":{"code":402,"message":"billing hard limit reached"}}', - ]; - for (const sample of realErrors) { - expect(isBillingErrorMessage(sample)).toBe(true); - } + + it("prefers billing when API-key and 402 hints both appear", () => { + const sample = + "402 Payment Required: The account associated with this API key has reached its maximum allowed monthly spending limit."; + expect(isBillingErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("billing"); }); }); describe("isCloudCodeAssistFormatError", () => { it("matches format errors", () => { - const samples = [ - "INVALID_REQUEST_ERROR: string should match pattern", - "messages.1.content.1.tool_use.id", - "tool_use.id should match pattern", - "invalid request format", - ]; - for (const sample of samples) { - expect(isCloudCodeAssistFormatError(sample)).toBe(true); - } + expectMessageMatches( + isCloudCodeAssistFormatError, + [ + "INVALID_REQUEST_ERROR: string should match pattern", + "messages.1.content.1.tool_use.id", + "tool_use.id should match pattern", + "invalid request format", + ], + true, + ); }); }); @@ -238,20 +247,24 @@ describe("isCloudflareOrHtmlErrorPage", () => { }); describe("isCompactionFailureError", () => { - it("matches compaction overflow failures", () => { - const samples = [ - 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', - "auto-compaction failed due to context overflow", - "Compaction failed: prompt is too long", - "Summarization failed: context window exceeded for this request", - ]; - for (const sample of samples) { - expect(isCompactionFailureError(sample)).toBe(true); - } - }); - it("ignores non-compaction overflow errors", () => { - expect(isCompactionFailureError("Context overflow: prompt too large")).toBe(false); - expect(isCompactionFailureError("rate limit exceeded")).toBe(false); + it.each([ + { + name: "matches compaction overflow failures", + samples: [ + 'Context overflow: Summarization failed: 400 {"message":"prompt is too long"}', + "auto-compaction failed due to context overflow", + "Compaction failed: prompt is too long", + "Summarization failed: context window exceeded for this request", + ], + expected: true, + }, + { + name: "ignores non-compaction overflow errors", + samples: ["Context overflow: prompt too large", "rate limit exceeded"], + expected: false, + }, + ])("$name", ({ samples, expected }) => { + expectMessageMatches(isCompactionFailureError, samples, expected); }); }); @@ -506,6 +519,10 @@ describe("isTransientHttpError", () => { }); describe("classifyFailoverReasonFromHttpStatus", () => { + it("treats HTTP 401 permanent auth failures as auth_permanent", () => { + expect(classifyFailoverReasonFromHttpStatus(401, "invalid_api_key")).toBe("auth_permanent"); + }); + it("treats HTTP 422 as format error", () => { expect(classifyFailoverReasonFromHttpStatus(422)).toBe("format"); expect(classifyFailoverReasonFromHttpStatus(422, "check open ai req parameter error")).toBe( @@ -518,6 +535,10 @@ describe("classifyFailoverReasonFromHttpStatus", () => { expect(classifyFailoverReasonFromHttpStatus(422, "insufficient credits")).toBe("billing"); }); + it("treats HTTP 400 insufficient-quota payloads as billing instead of format", () => { + expect(classifyFailoverReasonFromHttpStatus(400, INSUFFICIENT_QUOTA_PAYLOAD)).toBe("billing"); + }); + it("treats HTTP 499 as transient for structured errors", () => { expect(classifyFailoverReasonFromHttpStatus(499)).toBe("timeout"); expect(classifyFailoverReasonFromHttpStatus(499, "499 Client Closed Request")).toBe("timeout"); diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts index fceb809bbee..cd5238cf89b 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.policy.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as helpers from "./pi-embedded-helpers.js"; import { - expectGoogleModelApiFullSanitizeCall, loadSanitizeSessionHistoryWithCleanMocks, makeMockSessionManager, makeSimpleUserMessages, + type SanitizeSessionHistoryHarness, sanitizeSnapshotChangedOpenAIReasoning, sanitizeWithOpenAIResponses, } from "./pi-embedded-runner.sanitize-session-history.test-harness.js"; @@ -15,42 +14,43 @@ vi.mock("./pi-embedded-helpers.js", async () => ({ sanitizeSessionMessagesImages: vi.fn(async (msgs) => msgs), })); -type SanitizeSessionHistory = Awaited>; -let sanitizeSessionHistory: SanitizeSessionHistory; +let sanitizeSessionHistory: SanitizeSessionHistoryHarness["sanitizeSessionHistory"]; +let mockedHelpers: SanitizeSessionHistoryHarness["mockedHelpers"]; describe("sanitizeSessionHistory e2e smoke", () => { const mockSessionManager = makeMockSessionManager(); const mockMessages = makeSimpleUserMessages(); beforeEach(async () => { - sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); + const harness = await loadSanitizeSessionHistoryWithCleanMocks(); + sanitizeSessionHistory = harness.sanitizeSessionHistory; + mockedHelpers = harness.mockedHelpers; }); - it("applies full sanitize policy for google model APIs", async () => { - await expectGoogleModelApiFullSanitizeCall({ - sanitizeSessionHistory, + it("passes simple user-only history through for google model APIs", async () => { + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(true); + + const result = await sanitizeSessionHistory({ messages: mockMessages, + modelApi: "google-generative-ai", + provider: "google-vertex", sessionManager: mockSessionManager, + sessionId: "test-session", }); + + expect(result).toEqual(mockMessages); }); - it("keeps images-only sanitize policy without tool-call id rewriting for openai-responses", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + it("passes simple user-only history through for openai-responses", async () => { + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); - await sanitizeWithOpenAIResponses({ + const result = await sanitizeWithOpenAIResponses({ sanitizeSessionHistory, messages: mockMessages, sessionManager: mockSessionManager, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ - sanitizeMode: "images-only", - sanitizeToolCallIds: false, - }), - ); + expect(result).toEqual(mockMessages); }); it("downgrades openai reasoning blocks when the model snapshot changed", async () => { diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts index 97750fc1dbc..c0321852236 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test-harness.ts @@ -1,7 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { expect, vi } from "vitest"; -import * as helpers from "./pi-embedded-helpers.js"; export type SessionEntry = { type: string; customType: string; data: unknown }; export type SanitizeSessionHistoryFn = (params: { @@ -13,6 +12,11 @@ export type SanitizeSessionHistoryFn = (params: { sessionId: string; modelId?: string; }) => Promise; +export type SanitizeSessionHistoryMockedHelpers = typeof import("./pi-embedded-helpers.js"); +export type SanitizeSessionHistoryHarness = { + sanitizeSessionHistory: SanitizeSessionHistoryFn; + mockedHelpers: SanitizeSessionHistoryMockedHelpers; +}; export const TEST_SESSION_ID = "test-session"; export function makeModelSnapshotEntry(data: { @@ -54,11 +58,16 @@ export function makeSimpleUserMessages(): AgentMessage[] { return messages as unknown as AgentMessage[]; } -export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { +export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { + vi.resetModules(); vi.resetAllMocks(); - vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); + const mockedHelpers = await import("./pi-embedded-helpers.js"); + vi.mocked(mockedHelpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs); const mod = await import("./pi-embedded-runner/google.js"); - return mod.sanitizeSessionHistory; + return { + sanitizeSessionHistory: mod.sanitizeSessionHistory, + mockedHelpers, + }; } export function makeReasoningAssistantMessages(opts?: { @@ -118,26 +127,6 @@ export function expectOpenAIResponsesStrictSanitizeCall( ); } -export async function expectGoogleModelApiFullSanitizeCall(params: { - sanitizeSessionHistory: SanitizeSessionHistoryFn; - messages: AgentMessage[]; - sessionManager: SessionManager; -}) { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true); - await params.sanitizeSessionHistory({ - messages: params.messages, - modelApi: "google-generative-ai", - provider: "google-vertex", - sessionManager: params.sessionManager, - sessionId: TEST_SESSION_ID, - }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - params.messages, - "session:history", - expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }), - ); -} - export function makeSnapshotChangedOpenAIReasoningScenario() { const sessionEntries = [ makeModelSnapshotEntry({ diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 4fb4659c15d..2a71e0c95a3 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -1,9 +1,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as helpers from "./pi-embedded-helpers.js"; import { - expectGoogleModelApiFullSanitizeCall, loadSanitizeSessionHistoryWithCleanMocks, makeMockSessionManager, makeInMemorySessionManager, @@ -11,6 +9,7 @@ import { makeReasoningAssistantMessages, makeSimpleUserMessages, sanitizeSnapshotChangedOpenAIReasoning, + type SanitizeSessionHistoryHarness, type SanitizeSessionHistoryFn, sanitizeWithOpenAIResponses, TEST_SESSION_ID, @@ -25,6 +24,7 @@ vi.mock("./pi-embedded-helpers.js", async () => ({ })); let sanitizeSessionHistory: SanitizeSessionHistoryFn; +let mockedHelpers: SanitizeSessionHistoryHarness["mockedHelpers"]; let testTimestamp = 1; const nextTimestamp = () => testTimestamp++; @@ -35,7 +35,7 @@ describe("sanitizeSessionHistory", () => { const mockSessionManager = makeMockSessionManager(); const mockMessages = makeSimpleUserMessages(); const setNonGoogleModelApi = () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); }; const sanitizeGithubCopilotHistory = async (params: { @@ -52,6 +52,21 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); + const sanitizeAnthropicHistory = async (params: { + messages: AgentMessage[]; + provider?: string; + modelApi?: string; + modelId?: string; + }) => + sanitizeSessionHistory({ + messages: params.messages, + modelApi: params.modelApi ?? "anthropic-messages", + provider: params.provider ?? "anthropic", + modelId: params.modelId ?? "claude-opus-4-6", + sessionManager: makeMockSessionManager(), + sessionId: TEST_SESSION_ID, + }); + const getAssistantMessage = (messages: AgentMessage[]) => { expect(messages[1]?.role).toBe("assistant"); return messages[1] as Extract; @@ -164,21 +179,29 @@ describe("sanitizeSessionHistory", () => { beforeEach(async () => { testTimestamp = 1; - sanitizeSessionHistory = await loadSanitizeSessionHistoryWithCleanMocks(); + const harness = await loadSanitizeSessionHistoryWithCleanMocks(); + sanitizeSessionHistory = harness.sanitizeSessionHistory; + mockedHelpers = harness.mockedHelpers; }); - it("sanitizes tool call ids for Google model APIs", async () => { - await expectGoogleModelApiFullSanitizeCall({ - sanitizeSessionHistory, + it("passes simple user-only history through for Google model APIs", async () => { + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(true); + + const result = await sanitizeSessionHistory({ messages: mockMessages, + modelApi: "google-generative-ai", + provider: "google-vertex", sessionManager: mockSessionManager, + sessionId: TEST_SESSION_ID, }); + + expect(result).toEqual(mockMessages); }); - it("sanitizes tool call ids with strict9 for Mistral models", async () => { + it("passes simple user-only history through for Mistral models", async () => { setNonGoogleModelApi(); - await sanitizeSessionHistory({ + const result = await sanitizeSessionHistory({ messages: mockMessages, modelApi: "openai-responses", provider: "openrouter", @@ -187,21 +210,13 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ - sanitizeMode: "full", - sanitizeToolCallIds: true, - toolCallIdMode: "strict9", - }), - ); + expect(result).toEqual(mockMessages); }); - it("sanitizes tool call ids for Anthropic APIs", async () => { + it("passes simple user-only history through for Anthropic APIs", async () => { setNonGoogleModelApi(); - await sanitizeSessionHistory({ + const result = await sanitizeSessionHistory({ messages: mockMessages, modelApi: "anthropic-messages", provider: "anthropic", @@ -209,33 +224,25 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ sanitizeMode: "full", sanitizeToolCallIds: true }), - ); + expect(result).toEqual(mockMessages); }); - it("does not sanitize tool call ids for openai-responses", async () => { + it("passes simple user-only history through for openai-responses", async () => { setNonGoogleModelApi(); - await sanitizeWithOpenAIResponses({ + const result = await sanitizeWithOpenAIResponses({ sanitizeSessionHistory, messages: mockMessages, sessionManager: mockSessionManager, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }), - ); + expect(result).toEqual(mockMessages); }); - it("sanitizes tool call ids for openai-completions", async () => { + it("passes simple user-only history through for openai-completions", async () => { setNonGoogleModelApi(); - await sanitizeSessionHistory({ + const result = await sanitizeSessionHistory({ messages: mockMessages, modelApi: "openai-completions", provider: "openai", @@ -244,15 +251,7 @@ describe("sanitizeSessionHistory", () => { sessionId: TEST_SESSION_ID, }); - expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith( - mockMessages, - "session:history", - expect.objectContaining({ - sanitizeMode: "images-only", - sanitizeToolCallIds: true, - toolCallIdMode: "strict", - }), - ); + expect(result).toEqual(mockMessages); }); it("prepends a bootstrap user turn for strict OpenAI-compatible assistant-first history", async () => { @@ -314,7 +313,7 @@ describe("sanitizeSessionHistory", () => { }); it("drops stale assistant usage snapshots kept before latest compaction summary", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ { role: "user", content: "old context" }, @@ -335,7 +334,7 @@ describe("sanitizeSessionHistory", () => { }); it("preserves fresh assistant usage snapshots created after latest compaction summary", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ makeAssistantUsageMessage({ @@ -359,7 +358,7 @@ describe("sanitizeSessionHistory", () => { }); it("adds a zeroed assistant usage snapshot when usage is missing", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ { role: "user", content: "question" }, @@ -378,7 +377,7 @@ describe("sanitizeSessionHistory", () => { }); it("normalizes mixed partial assistant usage fields to numeric totals", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ { role: "user", content: "question" }, @@ -407,7 +406,7 @@ describe("sanitizeSessionHistory", () => { }); it("preserves existing usage cost while normalizing token fields", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ { role: "user", content: "question" }, @@ -451,7 +450,7 @@ describe("sanitizeSessionHistory", () => { }); it("preserves unknown cost when token fields already match", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const messages = castAgentMessages([ { role: "user", content: "question" }, @@ -484,7 +483,7 @@ describe("sanitizeSessionHistory", () => { }); it("drops stale usage when compaction summary appears before kept assistant messages", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); const messages = castAgentMessages([ @@ -505,7 +504,7 @@ describe("sanitizeSessionHistory", () => { }); it("keeps fresh usage after compaction timestamp in summary-first ordering", async () => { - vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false); + vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); const compactionTs = Date.parse("2026-02-26T12:00:00.000Z"); const messages = castAgentMessages([ @@ -776,22 +775,30 @@ describe("sanitizeSessionHistory", () => { expect(types).not.toContain("thinking"); }); - it("does not drop thinking blocks for non-copilot providers", async () => { + it("drops assistant thinking blocks for anthropic replay", async () => { setNonGoogleModelApi(); const messages = makeThinkingAndTextAssistantMessages(); - const result = await sanitizeSessionHistory({ + const result = await sanitizeAnthropicHistory({ messages }); + + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([{ type: "text", text: "hi" }]); + }); + + it("drops assistant thinking blocks for amazon-bedrock replay", async () => { + setNonGoogleModelApi(); + + const messages = makeThinkingAndTextAssistantMessages(); + + const result = await sanitizeAnthropicHistory({ messages, - modelApi: "anthropic-messages", - provider: "anthropic", - modelId: "claude-opus-4-6", - sessionManager: makeMockSessionManager(), - sessionId: TEST_SESSION_ID, + provider: "amazon-bedrock", + modelApi: "bedrock-converse-stream", }); - const types = getAssistantContentTypes(result); - expect(types).toContain("thinking"); + const assistant = getAssistantMessage(result); + expect(assistant.content).toEqual([{ type: "text", text: "hi" }]); }); it("does not drop thinking blocks for non-claude copilot models", async () => { diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 3e59f14af35..e3ef243b429 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -13,6 +13,7 @@ const { getMemorySearchManagerMock, resolveMemorySearchConfigMock, resolveSessionAgentIdMock, + estimateTokensMock, } = vi.hoisted(() => { const contextEngineCompactMock = vi.fn(async () => ({ ok: true as boolean, @@ -63,6 +64,7 @@ const { }, })), resolveSessionAgentIdMock: vi.fn(() => "main"), + estimateTokensMock: vi.fn((_message?: unknown) => 10), }; }); @@ -84,6 +86,11 @@ vi.mock("../../hooks/internal-hooks.js", async () => { }; }); +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: vi.fn(), + getOAuthProviders: vi.fn(() => []), +})); + vi.mock("@mariozechner/pi-coding-agent", () => { return { createAgentSession: vi.fn(async () => { @@ -122,7 +129,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => { SettingsManager: { create: vi.fn(() => ({})), }, - estimateTokens: vi.fn(() => 10), + estimateTokens: estimateTokensMock, }; }); @@ -363,6 +370,8 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { }); resolveSessionAgentIdMock.mockReset(); resolveSessionAgentIdMock.mockReturnValue("main"); + estimateTokensMock.mockReset(); + estimateTokensMock.mockReturnValue(10); unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); @@ -501,6 +510,79 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { } }); + it("preserves tokensAfter when full-session context exceeds result.tokensBefore", async () => { + estimateTokensMock.mockImplementation((message: unknown) => { + const role = (message as { role?: string }).role; + if (role === "user") { + return 30; + } + if (role === "assistant") { + return 20; + } + return 5; + }); + sessionCompactImpl.mockResolvedValue({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 20, + details: { ok: true }, + }); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + }); + + expect(result).toMatchObject({ + ok: true, + compacted: true, + result: { + tokensBefore: 20, + tokensAfter: 30, + }, + }); + expect(sessionHook("compact:after")?.context?.tokenCount).toBe(30); + }); + + it("treats pre-compaction token estimation failures as a no-op sanity check", async () => { + estimateTokensMock.mockImplementation((message: unknown) => { + const role = (message as { role?: string }).role; + if (role === "assistant") { + throw new Error("legacy message"); + } + if (role === "user") { + return 30; + } + return 5; + }); + sessionCompactImpl.mockResolvedValue({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 20, + details: { ok: true }, + }); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + }); + + expect(result).toMatchObject({ + ok: true, + compacted: true, + result: { + tokensAfter: 30, + }, + }); + expect(sessionHook("compact:after")?.context?.tokenCount).toBe(30); + }); + it("skips sync in await mode when postCompactionForce is false", async () => { const sync = vi.fn(async () => {}); getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 1207a0c3b0b..b465ea7dc9c 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -897,6 +897,17 @@ export async function compactEmbeddedPiSessionDirect( // Measure compactedCount from the original pre-limiting transcript so compaction // lifecycle metrics represent total reduction through the compaction pipeline. const messageCountCompactionInput = messageCountOriginal; + // Estimate full session tokens BEFORE compaction (including system prompt, + // bootstrap context, workspace files, and all history). This is needed for + // a correct sanity check β€” result.tokensBefore only covers the summarizable + // history subset, not the full session. + let fullSessionTokensBefore = 0; + try { + fullSessionTokensBefore = limited.reduce((sum, msg) => sum + estimateTokens(msg), 0); + } catch { + // If token estimation throws on a malformed message, fall back to 0 so + // the sanity check below becomes a no-op instead of crashing compaction. + } const result = await compactWithSafetyTimeout(() => session.compact(params.customInstructions), ); @@ -912,8 +923,15 @@ export async function compactEmbeddedPiSessionDirect( for (const message of session.messages) { tokensAfter += estimateTokens(message); } - // Sanity check: tokensAfter should be less than tokensBefore - if (tokensAfter > (observedTokenCount ?? result.tokensBefore)) { + // Sanity check: compare against the best full-session pre-compaction baseline. + // Prefer the provider-observed live count when available; otherwise use the + // heuristic full-session estimate with a 10% margin for counter jitter. + const sanityCheckBaseline = observedTokenCount ?? fullSessionTokensBefore; + if ( + sanityCheckBaseline > 0 && + tokensAfter > + (observedTokenCount !== undefined ? sanityCheckBaseline : sanityCheckBaseline * 1.1) + ) { tokensAfter = undefined; // Don't trust the estimate } } catch { diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts index 251063c6f19..08c1b0a3f70 100644 --- a/src/agents/pi-embedded-runner/extensions.ts +++ b/src/agents/pi-embedded-runner/extensions.ts @@ -84,6 +84,7 @@ export function buildEmbeddedExtensionFactories(params: { contextWindowTokens: contextWindowInfo.tokens, identifierPolicy: compactionCfg?.identifierPolicy, identifierInstructions: compactionCfg?.identifierInstructions, + customInstructions: compactionCfg?.customInstructions, qualityGuardEnabled: qualityGuardCfg?.enabled ?? false, qualityGuardMaxRetries: qualityGuardCfg?.maxRetries, model: params.model, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 3457fdf0161..274ef0ef865 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -1947,9 +1947,10 @@ export async function runEmbeddedAttempt( activeSession.agent.streamFn = cacheTrace.wrapStreamFn(activeSession.agent.streamFn); } - // Copilot/Claude can reject persisted `thinking` blocks (e.g. thinkingSignature:"reasoning_text") - // on *any* follow-up provider call (including tool continuations). Wrap the stream function - // so every outbound request sees sanitized messages. + // Anthropic Claude endpoints can reject replayed `thinking` blocks + // (e.g. thinkingSignature:"reasoning_text") on any follow-up provider + // call, including tool continuations. Wrap the stream function so every + // outbound request sees sanitized messages. if (transcriptPolicy.dropThinkingBlocks) { const inner = activeSession.agent.streamFn; activeSession.agent.streamFn = (model, context, options) => { diff --git a/src/agents/pi-extensions/compaction-instructions.test.ts b/src/agents/pi-extensions/compaction-instructions.test.ts new file mode 100644 index 00000000000..a75112d07cb --- /dev/null +++ b/src/agents/pi-extensions/compaction-instructions.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_COMPACTION_INSTRUCTIONS, + resolveCompactionInstructions, + composeSplitTurnInstructions, +} from "./compaction-instructions.js"; + +describe("DEFAULT_COMPACTION_INSTRUCTIONS", () => { + it("is a non-empty string", () => { + expect(typeof DEFAULT_COMPACTION_INSTRUCTIONS).toBe("string"); + expect(DEFAULT_COMPACTION_INSTRUCTIONS.trim().length).toBeGreaterThan(0); + }); + + it("contains language preservation directive", () => { + expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("primary language"); + }); + + it("contains factual content directive", () => { + expect(DEFAULT_COMPACTION_INSTRUCTIONS).toContain("factual content"); + }); + + it("does not exceed MAX_INSTRUCTION_LENGTH (800 chars)", () => { + expect(DEFAULT_COMPACTION_INSTRUCTIONS.length).toBeLessThanOrEqual(800); + }); +}); + +describe("resolveCompactionInstructions", () => { + describe("null / undefined handling", () => { + it("returns DEFAULT when both args are undefined", () => { + expect(resolveCompactionInstructions(undefined, undefined)).toBe( + DEFAULT_COMPACTION_INSTRUCTIONS, + ); + }); + + it("returns DEFAULT when both args are explicitly null (untyped JS caller)", () => { + expect( + resolveCompactionInstructions(null as unknown as undefined, null as unknown as undefined), + ).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + }); + + describe("empty and whitespace normalization", () => { + it("treats empty-string event as absent -- runtime wins", () => { + const result = resolveCompactionInstructions("", "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("treats whitespace-only event as absent -- runtime wins", () => { + const result = resolveCompactionInstructions(" ", "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("treats tab/newline-only event as absent -- runtime wins", () => { + const result = resolveCompactionInstructions("\t\n\r", "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("treats empty-string runtime as absent -- DEFAULT wins", () => { + const result = resolveCompactionInstructions(undefined, ""); + expect(result).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("treats whitespace-only runtime as absent -- DEFAULT wins", () => { + const result = resolveCompactionInstructions(undefined, " "); + expect(result).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("falls through to DEFAULT when both are empty strings", () => { + expect(resolveCompactionInstructions("", "")).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("falls through to DEFAULT when both are whitespace-only", () => { + expect(resolveCompactionInstructions(" ", "\t\n")).toBe(DEFAULT_COMPACTION_INSTRUCTIONS); + }); + + it("non-breaking space (\\u00A0) IS trimmed by ES2015+ trim() -- falls through", () => { + const nbsp = "\u00A0"; + const result = resolveCompactionInstructions(nbsp, "runtime"); + expect(result).toBe("runtime"); + }); + + it("KNOWN_EDGE: zero-width space (\\u200B) survives normalization -- invisible string used as instructions", () => { + const zws = "\u200B"; + const result = resolveCompactionInstructions(zws, "runtime"); + expect(result).toBe(zws); + }); + }); + + describe("precedence", () => { + it("event wins over runtime when both are non-empty", () => { + const result = resolveCompactionInstructions("event value", "runtime value"); + expect(result).toBe("event value"); + }); + + it("runtime wins when event is undefined", () => { + const result = resolveCompactionInstructions(undefined, "runtime value"); + expect(result).toBe("runtime value"); + }); + + it("event is trimmed before use", () => { + const result = resolveCompactionInstructions(" event ", "runtime"); + expect(result).toBe("event"); + }); + + it("runtime is trimmed before use", () => { + const result = resolveCompactionInstructions(undefined, " runtime "); + expect(result).toBe("runtime"); + }); + }); + + describe("truncation at 800 chars", () => { + it("does NOT truncate string of exactly 800 chars", () => { + const exact800 = "A".repeat(800); + const result = resolveCompactionInstructions(exact800, undefined); + expect(result).toHaveLength(800); + expect(result).toBe(exact800); + }); + + it("truncates string of 801 chars to 800", () => { + const over = "B".repeat(801); + const result = resolveCompactionInstructions(over, undefined); + expect(result).toHaveLength(800); + expect(result).toBe("B".repeat(800)); + }); + + it("truncates very long string to exactly 800", () => { + const huge = "C".repeat(5000); + const result = resolveCompactionInstructions(huge, undefined); + expect(result).toHaveLength(800); + }); + + it("truncation applies AFTER trimming -- 810 raw chars with 10 leading spaces yields 800", () => { + const padded = " ".repeat(10) + "D".repeat(800); + const result = resolveCompactionInstructions(padded, undefined); + expect(result).toHaveLength(800); + expect(result).toBe("D".repeat(800)); + }); + + it("truncation applies to runtime fallback as well", () => { + const longRuntime = "R".repeat(1000); + const result = resolveCompactionInstructions(undefined, longRuntime); + expect(result).toHaveLength(800); + }); + + it("truncates by code points, not code units (emoji safe)", () => { + const emojis801 = "\u{1F600}".repeat(801); + const result = resolveCompactionInstructions(emojis801, undefined); + expect(Array.from(result)).toHaveLength(800); + }); + + it("does not split surrogate pair when cut lands inside a pair", () => { + const input = "X" + "\u{1F600}".repeat(800); + const result = resolveCompactionInstructions(input, undefined); + const codePoints = Array.from(result); + expect(codePoints).toHaveLength(800); + expect(codePoints[0]).toBe("X"); + // Every code point in the truncated result must be a complete character (no lone surrogates) + for (const cp of codePoints) { + const code = cp.codePointAt(0)!; + const isLoneSurrogate = code >= 0xd800 && code <= 0xdfff; + expect(isLoneSurrogate).toBe(false); + } + }); + }); + + describe("return type", () => { + it("always returns a string, never undefined or null", () => { + const cases: [string | undefined, string | undefined][] = [ + [undefined, undefined], + ["", ""], + [" ", " "], + [null as unknown as undefined, null as unknown as undefined], + ["valid", undefined], + [undefined, "valid"], + ]; + + for (const [event, runtime] of cases) { + const result = resolveCompactionInstructions(event, runtime); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + } + }); + }); +}); + +describe("composeSplitTurnInstructions", () => { + it("joins turn prefix, separator, and resolved instructions with double newlines", () => { + const result = composeSplitTurnInstructions("Turn prefix here", "Resolved instructions here"); + expect(result).toBe( + "Turn prefix here\n\nAdditional requirements:\n\nResolved instructions here", + ); + }); + + it("output contains the turn prefix verbatim", () => { + const prefix = "Summarize the last 5 messages."; + const result = composeSplitTurnInstructions(prefix, "Keep it short."); + expect(result).toContain(prefix); + }); + + it("output contains the resolved instructions verbatim", () => { + const instructions = "Write in Korean. Preserve persona."; + const result = composeSplitTurnInstructions("prefix", instructions); + expect(result).toContain(instructions); + }); + + it("output contains 'Additional requirements:' separator", () => { + const result = composeSplitTurnInstructions("a", "b"); + expect(result).toContain("Additional requirements:"); + }); + + it("KNOWN_EDGE: empty turnPrefix produces leading blank line", () => { + const result = composeSplitTurnInstructions("", "instructions"); + expect(result).toBe("\n\nAdditional requirements:\n\ninstructions"); + expect(result.startsWith("\n")).toBe(true); + }); + + it("KNOWN_EDGE: empty resolvedInstructions produces trailing blank area", () => { + const result = composeSplitTurnInstructions("prefix", ""); + expect(result).toBe("prefix\n\nAdditional requirements:\n\n"); + expect(result.endsWith("\n\n")).toBe(true); + }); + + it("does not deduplicate if instructions already contain 'Additional requirements:'", () => { + const instructions = "Additional requirements: keep it short."; + const result = composeSplitTurnInstructions("prefix", instructions); + const count = (result.match(/Additional requirements:/g) || []).length; + expect(count).toBe(2); + }); + + it("preserves multiline content in both inputs", () => { + const prefix = "Line 1\nLine 2"; + const instructions = "Rule A\nRule B\nRule C"; + const result = composeSplitTurnInstructions(prefix, instructions); + expect(result).toContain("Line 1\nLine 2"); + expect(result).toContain("Rule A\nRule B\nRule C"); + }); +}); diff --git a/src/agents/pi-extensions/compaction-instructions.ts b/src/agents/pi-extensions/compaction-instructions.ts new file mode 100644 index 00000000000..104cf6cb90b --- /dev/null +++ b/src/agents/pi-extensions/compaction-instructions.ts @@ -0,0 +1,68 @@ +/** + * Compaction instruction utilities. + * + * Provides default language-preservation instructions and a precedence-based + * resolver for customInstructions used during context compaction summaries. + */ + +/** + * Default instructions injected into every safeguard-mode compaction summary. + * Preserves conversation language and persona while keeping the SDK's required + * summary structure intact. + */ +export const DEFAULT_COMPACTION_INSTRUCTIONS = + "Write the summary body in the primary language used in the conversation.\n" + + "Focus on factual content: what was discussed, decisions made, and current state.\n" + + "Keep the required summary structure and section headers unchanged.\n" + + "Do not translate or alter code, file paths, identifiers, or error messages."; + +/** + * Upper bound on custom instruction length to prevent prompt bloat. + * ~800 chars β‰ˆ ~200 tokens β€” keeps summarization quality stable. + */ +const MAX_INSTRUCTION_LENGTH = 800; + +function truncateUnicodeSafe(s: string, maxCodePoints: number): string { + const chars = Array.from(s); + if (chars.length <= maxCodePoints) { + return s; + } + return chars.slice(0, maxCodePoints).join(""); +} + +function normalize(s: string | undefined): string | undefined { + if (s == null) { + return undefined; + } + const trimmed = s.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Resolve compaction instructions with precedence: + * event (SDK) β†’ runtime (config) β†’ DEFAULT constant. + * + * Each input is normalized first (trim + emptyβ†’undefined) so that blank + * strings don't short-circuit the fallback chain. + */ +export function resolveCompactionInstructions( + eventInstructions: string | undefined, + runtimeInstructions: string | undefined, +): string { + const resolved = + normalize(eventInstructions) ?? + normalize(runtimeInstructions) ?? + DEFAULT_COMPACTION_INSTRUCTIONS; + return truncateUnicodeSafe(resolved, MAX_INSTRUCTION_LENGTH); +} + +/** + * Compose split-turn instructions by combining the SDK's turn-prefix + * instructions with the resolved compaction instructions. + */ +export function composeSplitTurnInstructions( + turnPrefixInstructions: string, + resolvedInstructions: string, +): string { + return [turnPrefixInstructions, "Additional requirements:", resolvedInstructions].join("\n\n"); +} diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts index 0180689f864..42ccb90aa49 100644 --- a/src/agents/pi-extensions/compaction-safeguard-runtime.ts +++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts @@ -7,6 +7,7 @@ export type CompactionSafeguardRuntimeValue = { contextWindowTokens?: number; identifierPolicy?: AgentCompactionIdentifierPolicy; identifierInstructions?: string; + customInstructions?: string; /** * Model to use for compaction summarization. * Passed through runtime because `ctx.model` is undefined in the compact.ts workflow diff --git a/src/agents/pi-extensions/compaction-safeguard.ts b/src/agents/pi-extensions/compaction-safeguard.ts index 6012aed604d..4461b97d3e0 100644 --- a/src/agents/pi-extensions/compaction-safeguard.ts +++ b/src/agents/pi-extensions/compaction-safeguard.ts @@ -23,6 +23,10 @@ import { collectTextContentBlocks } from "../content-blocks.js"; import { wrapUntrustedPromptDataBlock } from "../sanitize-for-prompt.js"; import { repairToolUseResultPairing } from "../session-transcript-repair.js"; import { extractToolCallsFromAssistant, extractToolResultId } from "../tool-call-id.js"; +import { + composeSplitTurnInstructions, + resolveCompactionInstructions, +} from "./compaction-instructions.js"; import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js"; const log = createSubsystemLogger("compaction-safeguard"); @@ -697,7 +701,7 @@ async function readWorkspaceContextForSummary(): Promise { export default function compactionSafeguardExtension(api: ExtensionAPI): void { api.on("session_before_compact", async (event, ctx) => { - const { preparation, customInstructions, signal } = event; + const { preparation, customInstructions: eventInstructions, signal } = event; if (!preparation.messagesToSummarize.some(isRealConversationMessage)) { log.warn( "Compaction safeguard: cancelling compaction with no real conversation messages to summarize.", @@ -715,6 +719,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // Model resolution: ctx.model is undefined in compact.ts workflow (extensionRunner.initialize() is never called). // Fall back to runtime.model which is explicitly passed when building extension paths. const runtime = getCompactionSafeguardRuntime(ctx.sessionManager); + const customInstructions = resolveCompactionInstructions( + eventInstructions, + runtime?.customInstructions, + ); const summarizationInstructions = { identifierPolicy: runtime?.identifierPolicy, identifierInstructions: runtime?.identifierInstructions, @@ -892,7 +900,10 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { reserveTokens, maxChunkTokens, contextWindow: contextWindowTokens, - customInstructions: `${TURN_PREFIX_INSTRUCTIONS}\n\n${currentInstructions}`, + customInstructions: composeSplitTurnInstructions( + TURN_PREFIX_INSTRUCTIONS, + currentInstructions, + ), summarizationInstructions, previousSummary: undefined, }); diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 90d2b52ff5a..ef59f025de8 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -22,7 +22,19 @@ describe("resolveProviderCapabilities", () => { transcriptToolCallIdMode: "default", transcriptToolCallIdModelHints: [], geminiThoughtSignatureModelHints: [], - dropThinkingBlockModelHints: [], + dropThinkingBlockModelHints: ["claude"], + }); + expect(resolveProviderCapabilities("amazon-bedrock")).toEqual({ + anthropicToolSchemaMode: "native", + anthropicToolChoiceMode: "native", + providerFamily: "anthropic", + preserveAnthropicThinkingSignatures: true, + openAiCompatTurnValidation: true, + geminiThoughtSignatureSanitization: false, + transcriptToolCallIdMode: "default", + transcriptToolCallIdModelHints: [], + geminiThoughtSignatureModelHints: [], + dropThinkingBlockModelHints: ["claude"], }); }); @@ -82,6 +94,18 @@ describe("resolveProviderCapabilities", () => { it("tracks provider families and model-specific transcript quirks in the registry", () => { expect(isOpenAiProviderFamily("openai")).toBe(true); expect(isAnthropicProviderFamily("amazon-bedrock")).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "anthropic", + modelId: "claude-opus-4-6", + }), + ).toBe(true); + expect( + shouldDropThinkingBlocksForModel({ + provider: "amazon-bedrock", + modelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + }), + ).toBe(true); expect( shouldDropThinkingBlocksForModel({ provider: "github-copilot", diff --git a/src/agents/provider-capabilities.ts b/src/agents/provider-capabilities.ts index 27aadbcd7d3..f443fac4d11 100644 --- a/src/agents/provider-capabilities.ts +++ b/src/agents/provider-capabilities.ts @@ -29,9 +29,11 @@ const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = { const PROVIDER_CAPABILITIES: Record> = { anthropic: { providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], }, "amazon-bedrock": { providerFamily: "anthropic", + dropThinkingBlockModelHints: ["claude"], }, // kimi-coding natively supports Anthropic tool framing (input_schema); // converting to OpenAI format causes XML text fallback instead of tool_use blocks. diff --git a/src/agents/spawned-context.test.ts b/src/agents/spawned-context.test.ts index 964bf47a789..3f163eb3030 100644 --- a/src/agents/spawned-context.test.ts +++ b/src/agents/spawned-context.test.ts @@ -44,18 +44,44 @@ describe("mapToolContextToSpawnedRunMetadata", () => { }); describe("resolveSpawnedWorkspaceInheritance", () => { + const config = { + agents: { + list: [ + { id: "main", workspace: "/tmp/workspace-main" }, + { id: "ops", workspace: "/tmp/workspace-ops" }, + ], + }, + }; + it("prefers explicit workspaceDir when provided", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: "agent:main:subagent:parent", explicitWorkspaceDir: " /tmp/explicit ", }); expect(resolved).toBe("/tmp/explicit"); }); + it("prefers targetAgentId over requester session agent for cross-agent spawns", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + targetAgentId: "ops", + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-ops"); + }); + + it("falls back to requester session agent when targetAgentId is missing", () => { + const resolved = resolveSpawnedWorkspaceInheritance({ + config, + requesterSessionKey: "agent:main:subagent:parent", + }); + expect(resolved).toBe("/tmp/workspace-main"); + }); + it("returns undefined for missing requester context", () => { const resolved = resolveSpawnedWorkspaceInheritance({ - config: {}, + config, requesterSessionKey: undefined, explicitWorkspaceDir: undefined, }); diff --git a/src/agents/spawned-context.ts b/src/agents/spawned-context.ts index 32a4d299e74..d0919c86baa 100644 --- a/src/agents/spawned-context.ts +++ b/src/agents/spawned-context.ts @@ -58,6 +58,7 @@ export function mapToolContextToSpawnedRunMetadata( export function resolveSpawnedWorkspaceInheritance(params: { config: OpenClawConfig; + targetAgentId?: string; requesterSessionKey?: string; explicitWorkspaceDir?: string | null; }): string | undefined { @@ -65,12 +66,13 @@ export function resolveSpawnedWorkspaceInheritance(params: { if (explicit) { return explicit; } - const requesterAgentId = params.requesterSessionKey - ? parseAgentSessionKey(params.requesterSessionKey)?.agentId - : undefined; - return requesterAgentId - ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(requesterAgentId)) - : undefined; + // For cross-agent spawns, use the target agent's workspace instead of the requester's. + const agentId = + params.targetAgentId ?? + (params.requesterSessionKey + ? parseAgentSessionKey(params.requesterSessionKey)?.agentId + : undefined); + return agentId ? resolveAgentWorkspaceDir(params.config, normalizeAgentId(agentId)) : undefined; } export function resolveIngressWorkspaceOverrideForSpawnedRun( diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index 468de55953c..32f2e06311e 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -343,6 +343,35 @@ describe("subagent registry persistence", () => { expect(afterSecond.runs["run-3"].cleanupCompletedAt).toBeDefined(); }); + it("retries cleanup announce after announce flow rejects", async () => { + const persisted = createPersistedEndedRun({ + runId: "run-reject", + childSessionKey: "agent:main:subagent:reject", + task: "reject announce", + cleanup: "keep", + }); + const registryPath = await writePersistedRegistry(persisted); + + announceSpy.mockRejectedValueOnce(new Error("announce boom")); + await restartRegistryAndFlush(); + + expect(announceSpy).toHaveBeenCalledTimes(1); + const afterFirst = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs: Record; + }; + expect(afterFirst.runs["run-reject"].cleanupHandled).toBe(false); + expect(afterFirst.runs["run-reject"].cleanupCompletedAt).toBeUndefined(); + + announceSpy.mockResolvedValueOnce(true); + await restartRegistryAndFlush(); + + expect(announceSpy).toHaveBeenCalledTimes(2); + const afterSecond = JSON.parse(await fs.readFile(registryPath, "utf8")) as { + runs: Record; + }; + expect(afterSecond.runs["run-reject"].cleanupCompletedAt).toBeDefined(); + }); + it("keeps delete-mode runs retryable when announce is deferred", async () => { const persisted = createPersistedEndedRun({ runId: "run-4", diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index 477544bdd3d..d9c593c3e84 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -534,6 +534,18 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor return false; } const requesterOrigin = normalizeDeliveryContext(entry.requesterOrigin); + const finalizeAnnounceCleanup = (didAnnounce: boolean) => { + void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce).catch((err) => { + defaultRuntime.log(`[warn] subagent cleanup finalize failed (${runId}): ${String(err)}`); + const current = subagentRuns.get(runId); + if (!current || current.cleanupCompletedAt) { + return; + } + current.cleanupHandled = false; + persistSubagentRuns(); + }); + }; + void runSubagentAnnounceFlow({ childSessionKey: entry.childSessionKey, childRunId: entry.runId, @@ -555,13 +567,13 @@ function startSubagentAnnounceCleanupFlow(runId: string, entry: SubagentRunRecor wakeOnDescendantSettle: entry.wakeOnDescendantSettle === true, }) .then((didAnnounce) => { - void finalizeSubagentCleanup(runId, entry.cleanup, didAnnounce); + finalizeAnnounceCleanup(didAnnounce); }) .catch((error) => { defaultRuntime.log( `[warn] Subagent announce flow failed during cleanup for run ${runId}: ${String(error)}`, ); - void finalizeSubagentCleanup(runId, entry.cleanup, false); + finalizeAnnounceCleanup(false); }); return true; } diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index a4a6229c715..1750d948e6c 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -576,8 +576,11 @@ export async function spawnSubagentDirect( ...toolSpawnMetadata, workspaceDir: resolveSpawnedWorkspaceInheritance({ config: cfg, - requesterSessionKey: requesterInternalKey, - explicitWorkspaceDir: toolSpawnMetadata.workspaceDir, + targetAgentId, + // For cross-agent spawns, ignore the caller's inherited workspace; + // let targetAgentId resolve the correct workspace instead. + explicitWorkspaceDir: + targetAgentId !== requesterAgentId ? undefined : toolSpawnMetadata.workspaceDir, }), }); const spawnLineagePatchError = await patchChildSession({ diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts new file mode 100644 index 00000000000..fef6bc7515c --- /dev/null +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { spawnSubagentDirect } from "./subagent-spawn.js"; + +type TestAgentConfig = { + id?: string; + workspace?: string; + subagents?: { + allowAgents?: string[]; + }; +}; + +type TestConfig = { + agents?: { + list?: TestAgentConfig[]; + }; +}; + +const hoisted = vi.hoisted(() => ({ + callGatewayMock: vi.fn(), + configOverride: {} as Record, + registerSubagentRunMock: vi.fn(), +})); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => hoisted.configOverride, + }; +}); + +vi.mock("@mariozechner/pi-ai/oauth", () => ({ + getOAuthApiKey: () => "", + getOAuthProviders: () => [], +})); + +vi.mock("./subagent-registry.js", () => ({ + countActiveRunsForSession: () => 0, + registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args), +})); + +vi.mock("./subagent-announce.js", () => ({ + buildSubagentSystemPrompt: () => "system-prompt", +})); + +vi.mock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, +})); + +vi.mock("./model-selection.js", () => ({ + resolveSubagentSpawnModelSelection: () => undefined, +})); + +vi.mock("./sandbox/runtime-status.js", () => ({ + resolveSandboxRuntimeStatus: () => ({ sandboxed: false }), +})); + +vi.mock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), +})); + +vi.mock("../utils/delivery-context.js", () => ({ + normalizeDeliveryContext: (value: unknown) => value, +})); + +vi.mock("./tools/sessions-helpers.js", () => ({ + resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), + resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", +})); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentConfig: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId), + resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ?? + `/tmp/workspace-${agentId}`, +})); + +function createConfigOverride(overrides?: Record) { + return { + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + }, + ], + }, + ...overrides, + }; +} + +function setupGatewayMock() { + hoisted.callGatewayMock.mockImplementation( + async (opts: { method?: string; params?: Record }) => { + if (opts.method === "sessions.patch") { + return { ok: true }; + } + if (opts.method === "sessions.delete") { + return { ok: true }; + } + if (opts.method === "agent") { + return { runId: "run-1" }; + } + return {}; + }, + ); +} + +function getRegisteredRun() { + return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as + | Record + | undefined; +} + +describe("spawnSubagentDirect workspace inheritance", () => { + beforeEach(() => { + hoisted.callGatewayMock.mockClear(); + hoisted.registerSubagentRunMock.mockClear(); + hoisted.configOverride = createConfigOverride(); + setupGatewayMock(); + }); + + it("uses the target agent workspace for cross-agent spawns", async () => { + hoisted.configOverride = createConfigOverride({ + agents: { + list: [ + { + id: "main", + workspace: "/tmp/workspace-main", + subagents: { + allowAgents: ["ops"], + }, + }, + { + id: "ops", + workspace: "/tmp/workspace-ops", + }, + ], + }, + }); + + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "ops", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/workspace-ops", + }); + }); + + it("preserves the inherited workspace for same-agent spawns", async () => { + const result = await spawnSubagentDirect( + { + task: "inspect workspace", + agentId: "main", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "123", + agentTo: "456", + workspaceDir: "/tmp/requester-workspace", + }, + ); + + expect(result.status).toBe("accepted"); + expect(getRegisteredRun()).toMatchObject({ + workspaceDir: "/tmp/requester-workspace", + }); + }); +}); diff --git a/src/agents/tool-policy-pipeline.test.ts b/src/agents/tool-policy-pipeline.test.ts index 9d0a9d5846f..70d4301d42a 100644 --- a/src/agents/tool-policy-pipeline.test.ts +++ b/src/agents/tool-policy-pipeline.test.ts @@ -45,6 +45,31 @@ describe("tool-policy-pipeline", () => { expect(warnings[0]).toContain("unknown entries (wat)"); }); + test("warns gated core tools as unavailable instead of plugin-only unknowns", () => { + const warnings: string[] = []; + const tools = [{ name: "exec" }] as unknown as DummyTool[]; + applyToolPolicyPipeline({ + // oxlint-disable-next-line typescript/no-explicit-any + tools: tools as any, + // oxlint-disable-next-line typescript/no-explicit-any + toolMeta: () => undefined, + warn: (msg) => warnings.push(msg), + steps: [ + { + policy: { allow: ["apply_patch"] }, + label: "tools.profile (coding)", + stripPluginOnlyAllowlist: true, + }, + ], + }); + expect(warnings.length).toBe(1); + expect(warnings[0]).toContain("unknown entries (apply_patch)"); + expect(warnings[0]).toContain( + "shipped core tools but unavailable in the current runtime/provider/model/config", + ); + expect(warnings[0]).not.toContain("unless the plugin is enabled"); + }); + test("applies allowlist filtering when core tools are explicitly listed", () => { const tools = [{ name: "exec" }, { name: "process" }] as unknown as DummyTool[]; const filtered = applyToolPolicyPipeline({ diff --git a/src/agents/tool-policy-pipeline.ts b/src/agents/tool-policy-pipeline.ts index d3304a020d6..70a7bddaf29 100644 --- a/src/agents/tool-policy-pipeline.ts +++ b/src/agents/tool-policy-pipeline.ts @@ -1,5 +1,6 @@ import { filterToolsByPolicy } from "./pi-tools.policy.js"; import type { AnyAgentTool } from "./pi-tools.types.js"; +import { isKnownCoreToolId } from "./tool-catalog.js"; import { buildPluginToolGroups, expandPolicyWithPluginGroups, @@ -91,9 +92,15 @@ export function applyToolPolicyPipeline(params: { const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames); if (resolved.unknownAllowlist.length > 0) { const entries = resolved.unknownAllowlist.join(", "); - const suffix = resolved.strippedAllowlist - ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement." - : "These entries won't match any tool unless the plugin is enabled."; + const gatedCoreEntries = resolved.unknownAllowlist.filter((entry) => + isKnownCoreToolId(entry), + ); + const otherEntries = resolved.unknownAllowlist.filter((entry) => !isKnownCoreToolId(entry)); + const suffix = describeUnknownAllowlistSuffix({ + strippedAllowlist: resolved.strippedAllowlist, + hasGatedCoreEntries: gatedCoreEntries.length > 0, + hasOtherEntries: otherEntries.length > 0, + }); params.warn( `tools: ${step.label} allowlist contains unknown entries (${entries}). ${suffix}`, ); @@ -106,3 +113,20 @@ export function applyToolPolicyPipeline(params: { } return filtered; } + +function describeUnknownAllowlistSuffix(params: { + strippedAllowlist: boolean; + hasGatedCoreEntries: boolean; + hasOtherEntries: boolean; +}): string { + const preface = params.strippedAllowlist + ? "Ignoring allowlist so core tools remain available." + : ""; + const detail = + params.hasGatedCoreEntries && params.hasOtherEntries + ? "Some entries are shipped core tools but unavailable in the current runtime/provider/model/config; other entries won't match any tool unless the plugin is enabled." + : params.hasGatedCoreEntries + ? "These entries are shipped core tools but unavailable in the current runtime/provider/model/config." + : "These entries won't match any tool unless the plugin is enabled."; + return preface ? `${preface} ${detail}` : detail; +} diff --git a/src/agents/transcript-policy.ts b/src/agents/transcript-policy.ts index d6d9ec5916a..46795bad1bc 100644 --- a/src/agents/transcript-policy.ts +++ b/src/agents/transcript-policy.ts @@ -80,9 +80,9 @@ export function resolveTranscriptPolicy(params: { }); const requiresOpenAiCompatibleToolIdSanitization = params.modelApi === "openai-completions"; - // GitHub Copilot's Claude endpoints can reject persisted `thinking` blocks with - // non-binary/non-base64 signatures (e.g. thinkingSignature: "reasoning_text"). - // Drop these blocks at send-time to keep sessions usable. + // Anthropic Claude endpoints can reject replayed `thinking` blocks unless the + // original signatures are preserved byte-for-byte. Drop them at send-time to + // keep persisted sessions usable across follow-up turns. const dropThinkingBlocks = shouldDropThinkingBlocksForModel({ provider, modelId }); const needsNonImageSanitize = diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 830b44504ad..c4f1044a8d9 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -458,41 +458,24 @@ export async function ensureAgentWorkspace(params?: { }; } -async function resolveMemoryBootstrapEntries( +async function resolveMemoryBootstrapEntry( resolvedDir: string, -): Promise> { - const candidates: WorkspaceBootstrapFileName[] = [ - DEFAULT_MEMORY_FILENAME, - DEFAULT_MEMORY_ALT_FILENAME, - ]; - const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; - for (const name of candidates) { +): Promise<{ name: WorkspaceBootstrapFileName; filePath: string } | null> { + // Prefer MEMORY.md; fall back to memory.md only when absent. + // Checking both and deduplicating via realpath is unreliable on case-insensitive + // file systems mounted in Docker (e.g. macOS volumes), where both names pass + // fs.access() but realpath does not normalise case through the mount layer, + // causing the same content to be injected twice and wasting tokens. + for (const name of [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME] as const) { const filePath = path.join(resolvedDir, name); try { await fs.access(filePath); - entries.push({ name, filePath }); + return { name, filePath }; } catch { - // optional + // try next candidate } } - if (entries.length <= 1) { - return entries; - } - - const seen = new Set(); - const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; - for (const entry of entries) { - let key = entry.filePath; - try { - key = await fs.realpath(entry.filePath); - } catch {} - if (seen.has(key)) { - continue; - } - seen.add(key); - deduped.push(entry); - } - return deduped; + return null; } export async function loadWorkspaceBootstrapFiles(dir: string): Promise { @@ -532,7 +515,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise { @@ -368,7 +368,7 @@ Read WORKFLOW.md on startup. expect(result).not.toBeNull(); expect(result).toContain("Do startup things"); expect(result).toContain("Be safe"); - expect(result).toContain("Execute your Session Startup sequence now"); + expect(result).toContain("Run your Session Startup sequence"); }); it("custom section names are matched case-insensitively", async () => { diff --git a/src/auto-reply/reply/post-compaction-context.ts b/src/auto-reply/reply/post-compaction-context.ts index 316ac3c29b1..791c1a91a19 100644 --- a/src/auto-reply/reply/post-compaction-context.ts +++ b/src/auto-reply/reply/post-compaction-context.ts @@ -136,7 +136,7 @@ export async function readPostCompactionContext( // would be misleading for deployments that use different section names. const prose = isDefaultSections ? "Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " + - "Execute your Session Startup sequence now β€” read the required files before responding to the user." + "Run your Session Startup sequence β€” read the required files before responding to the user." : `Session was just compacted. The conversation summary above is a hint, NOT a substitute for your full startup sequence. ` + `Re-read the sections injected below (${displayNames.join(", ")}) and follow your configured startup procedure before responding to the user.`; diff --git a/src/auto-reply/reply/session-reset-prompt.test.ts b/src/auto-reply/reply/session-reset-prompt.test.ts index c6a1d2d9562..bf038243962 100644 --- a/src/auto-reply/reply/session-reset-prompt.test.ts +++ b/src/auto-reply/reply/session-reset-prompt.test.ts @@ -5,7 +5,7 @@ import { buildBareSessionResetPrompt } from "./session-reset-prompt.js"; describe("buildBareSessionResetPrompt", () => { it("includes the core session startup instruction", () => { const prompt = buildBareSessionResetPrompt(); - expect(prompt).toContain("Execute your Session Startup sequence now"); + expect(prompt).toContain("Run your Session Startup sequence"); expect(prompt).toContain("read the required files before responding to the user"); }); diff --git a/src/auto-reply/reply/session-reset-prompt.ts b/src/auto-reply/reply/session-reset-prompt.ts index 67e693f70b1..c903e3a688a 100644 --- a/src/auto-reply/reply/session-reset-prompt.ts +++ b/src/auto-reply/reply/session-reset-prompt.ts @@ -2,7 +2,7 @@ import { appendCronStyleCurrentTimeLine } from "../../agents/current-time.js"; import type { OpenClawConfig } from "../../config/config.js"; const BARE_SESSION_RESET_PROMPT_BASE = - "A new session was started via /new or /reset. Execute your Session Startup sequence now - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; + "A new session was started via /new or /reset. Run your Session Startup sequence - read the required files before responding to the user. Then greet the user in your configured persona, if one is provided. Be yourself - use your defined voice, mannerisms, and mood. Keep it to 1-3 sentences and ask what they want to do. If the runtime model differs from default_model in the system prompt, mention the default model. Do not mention internal steps, files, tools, or reasoning."; /** * Build the bare session reset prompt, appending the current date/time so agents diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 4692d442ea5..be32e3635e1 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -54,6 +54,10 @@ export type GetReplyOptions = { onToolResult?: (payload: ReplyPayload) => Promise | void; /** Called when a tool phase starts/updates, before summary payloads are emitted. */ onToolStart?: (payload: { name?: string; phase?: string }) => Promise | void; + /** Called when context auto-compaction starts (allows UX feedback during the pause). */ + onCompactionStart?: () => Promise | void; + /** Called when context auto-compaction completes. */ + onCompactionEnd?: () => Promise | void; /** Called when the actual model is selected (including after fallback). * Use this to get model/provider/thinkLevel for responsePrefix template interpolation. */ onModelSelected?: (ctx: ModelSelectedContext) => void; diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 13cd05d65c3..72f589bc0a7 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -8,7 +8,7 @@ function resolveDirectoryLimit(limit?: number | null): number | undefined { return typeof limit === "number" && limit > 0 ? limit : undefined; } -function applyDirectoryQueryAndLimit( +export function applyDirectoryQueryAndLimit( ids: string[], params: { query?: string | null; limit?: number | null }, ): string[] { @@ -18,7 +18,7 @@ function applyDirectoryQueryAndLimit( return typeof limit === "number" ? filtered.slice(0, limit) : filtered; } -function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { +export function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { return ids.map((id) => ({ kind, id }) as const); } diff --git a/src/channels/plugins/directory-config.ts b/src/channels/plugins/directory-config.ts index eaf35fa33ef..e1270a9ceed 100644 --- a/src/channels/plugins/directory-config.ts +++ b/src/channels/plugins/directory-config.ts @@ -5,6 +5,7 @@ import { inspectSlackAccount } from "../../slack/account-inspect.js"; import { inspectTelegramAccount } from "../../telegram/account-inspect.js"; import { resolveWhatsAppAccount } from "../../web/accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; +import { applyDirectoryQueryAndLimit, toDirectoryEntries } from "./directory-config-helpers.js"; import { normalizeSlackMessagingTarget } from "./normalize/slack.js"; import type { ChannelDirectoryEntry } from "./types.js"; @@ -54,25 +55,6 @@ function normalizeTrimmedSet( .filter((id): id is string => Boolean(id)); } -function resolveDirectoryQuery(query?: string | null): string { - return query?.trim().toLowerCase() || ""; -} - -function resolveDirectoryLimit(limit?: number | null): number | undefined { - return typeof limit === "number" && limit > 0 ? limit : undefined; -} - -function applyDirectoryQueryAndLimit(ids: string[], params: DirectoryConfigParams): string[] { - const q = resolveDirectoryQuery(params.query); - const limit = resolveDirectoryLimit(params.limit); - const filtered = ids.filter((id) => (q ? id.toLowerCase().includes(q) : true)); - return typeof limit === "number" ? filtered.slice(0, limit) : filtered; -} - -function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] { - return ids.map((id) => ({ kind, id }) as const); -} - export async function listSlackDirectoryPeersFromConfig( params: DirectoryConfigParams, ): Promise { diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index 9617798325d..ea813fcf75b 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -28,34 +28,58 @@ type SendPayloadAdapter = Pick< "sendMedia" | "sendText" | "chunker" | "textChunkLimit" >; +export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { + return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; +} + +export async function sendPayloadMediaSequence(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; +}): Promise { + let lastResult: TResult | undefined; + for (let i = 0; i < params.mediaUrls.length; i += 1) { + const mediaUrl = params.mediaUrls[i]; + if (!mediaUrl) { + continue; + } + lastResult = await params.send({ + text: i === 0 ? params.text : "", + mediaUrl, + index: i, + isFirst: i === 0, + }); + } + return lastResult; +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; adapter: SendPayloadAdapter; }): Promise { const text = params.ctx.payload.text ?? ""; - const urls = params.ctx.payload.mediaUrls?.length - ? params.ctx.payload.mediaUrls - : params.ctx.payload.mediaUrl - ? [params.ctx.payload.mediaUrl] - : []; + const urls = resolvePayloadMediaUrls(params.ctx.payload); if (!text && urls.length === 0) { return { channel: params.channel, messageId: "" }; } if (urls.length > 0) { - let lastResult = await params.adapter.sendMedia!({ - ...params.ctx, + const lastResult = await sendPayloadMediaSequence({ text, - mediaUrl: urls[0], + mediaUrls: urls, + send: async ({ text, mediaUrl }) => + await params.adapter.sendMedia!({ + ...params.ctx, + text, + mediaUrl, + }), }); - for (let i = 1; i < urls.length; i++) { - lastResult = await params.adapter.sendMedia!({ - ...params.ctx, - text: "", - mediaUrl: urls[i], - }); - } - return lastResult; + return lastResult ?? { channel: params.channel, messageId: "" }; } const limit = params.adapter.textChunkLimit; const chunks = limit && params.adapter.chunker ? params.adapter.chunker(text, limit) : [text]; diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 8af1b5831ee..c96a44a7047 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -8,6 +8,7 @@ import { } from "../../../telegram/outbound-params.js"; import { sendMessageTelegram } from "../../../telegram/send.js"; import type { ChannelOutboundAdapter } from "../types.js"; +import { resolvePayloadMediaUrls, sendPayloadMediaSequence } from "./direct-text-media.js"; type TelegramSendFn = typeof sendMessageTelegram; type TelegramSendOpts = Parameters[2]; @@ -55,11 +56,7 @@ export async function sendTelegramPayloadMessages(params: { const quoteText = typeof telegramData?.quoteText === "string" ? telegramData.quoteText : undefined; const text = params.payload.text ?? ""; - const mediaUrls = params.payload.mediaUrls?.length - ? params.payload.mediaUrls - : params.payload.mediaUrl - ? [params.payload.mediaUrl] - : []; + const mediaUrls = resolvePayloadMediaUrls(params.payload); const payloadOpts = { ...params.baseOpts, quoteText, @@ -73,16 +70,16 @@ export async function sendTelegramPayloadMessages(params: { } // Telegram allows reply_markup on media; attach buttons only to the first send. - let finalResult: Awaited> | undefined; - for (let i = 0; i < mediaUrls.length; i += 1) { - const mediaUrl = mediaUrls[i]; - const isFirst = i === 0; - finalResult = await params.send(params.to, isFirst ? text : "", { - ...payloadOpts, - mediaUrl, - ...(isFirst ? { buttons: telegramData?.buttons } : {}), - }); - } + const finalResult = await sendPayloadMediaSequence({ + text, + mediaUrls, + send: async ({ text, mediaUrl, isFirst }) => + await params.send(params.to, text, { + ...payloadOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }), + }); return finalResult ?? { messageId: "unknown", chatId: params.to }; } diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 9b61946d64e..41611c22b1a 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -148,6 +148,15 @@ describe("createStatusReactionController", () => { expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.thinking }); }); + it("should debounce setCompacting and eventually call adapter", async () => { + const { calls, controller } = createEnabledController(); + + void controller.setCompacting(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + expect(calls).toContainEqual({ method: "set", emoji: DEFAULT_EMOJIS.compacting }); + }); + it("should classify tool name and debounce", async () => { const { calls, controller } = createEnabledController(); @@ -245,6 +254,19 @@ describe("createStatusReactionController", () => { expect(calls.length).toBe(callsAfterFirst); }); + it("should cancel a pending compacting emoji before resuming thinking", async () => { + const { calls, controller } = createEnabledController(); + + void controller.setCompacting(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs - 1); + controller.cancelPending(); + void controller.setThinking(); + await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); + + const setEmojis = calls.filter((call) => call.method === "set").map((call) => call.emoji); + expect(setEmojis).toEqual([DEFAULT_EMOJIS.thinking]); + }); + it("should call removeReaction when adapter supports it and emoji changes", async () => { const { calls, controller } = createEnabledController(); @@ -446,6 +468,7 @@ describe("constants", () => { const emojiKeys = [ "queued", "thinking", + "compacting", "tool", "coding", "web", diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts index 4b0651232c8..060555a997c 100644 --- a/src/channels/status-reactions.ts +++ b/src/channels/status-reactions.ts @@ -24,6 +24,7 @@ export type StatusReactionEmojis = { error?: string; // Default: "❌" stallSoft?: string; // Default: "⏳" stallHard?: string; // Default: "⚠️" + compacting?: string; // Default: "✍" }; export type StatusReactionTiming = { @@ -38,6 +39,9 @@ export type StatusReactionController = { setQueued: () => Promise | void; setThinking: () => Promise | void; setTool: (toolName?: string) => Promise | void; + setCompacting: () => Promise | void; + /** Cancel any pending debounced emoji (useful before forcing a state transition). */ + cancelPending: () => void; setDone: () => Promise; setError: () => Promise; clear: () => Promise; @@ -58,6 +62,7 @@ export const DEFAULT_EMOJIS: Required = { error: "😱", stallSoft: "πŸ₯±", stallHard: "😨", + compacting: "✍", }; export const DEFAULT_TIMING: Required = { @@ -162,6 +167,7 @@ export function createStatusReactionController(params: { emojis.error, emojis.stallSoft, emojis.stallHard, + emojis.compacting, ]); /** @@ -306,6 +312,15 @@ export function createStatusReactionController(params: { scheduleEmoji(emoji); } + function setCompacting(): void { + scheduleEmoji(emojis.compacting); + } + + function cancelPending(): void { + clearDebounceTimer(); + pendingEmoji = ""; + } + function finishWithEmoji(emoji: string): Promise { if (!enabled) { return Promise.resolve(); @@ -375,6 +390,8 @@ export function createStatusReactionController(params: { setQueued, setThinking, setTool, + setCompacting, + cancelPending, setDone, setError, clear, diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 05025dc05e6..bd7d0ff1af5 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -81,7 +81,10 @@ export function registerCronAddCommand(cron: Command) { .option("--exact", "Disable cron staggering (set stagger to 0)", false) .option("--system-event ", "System event payload (main session)") .option("--message ", "Agent message payload") - .option("--thinking ", "Thinking level for agent jobs (off|minimal|low|medium|high)") + .option( + "--thinking ", + "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)", + ) .option("--model ", "Model override for agent jobs (provider/model or alias)") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Use lightweight bootstrap context for agent jobs", false) diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 35bf45907f9..b2007fc3f1a 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -49,7 +49,10 @@ export function registerCronEditCommand(cron: Command) { .option("--exact", "Disable cron staggering (set stagger to 0)") .option("--system-event ", "Set systemEvent payload") .option("--message ", "Set agentTurn payload message") - .option("--thinking ", "Thinking level for agent jobs") + .option( + "--thinking ", + "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)", + ) .option("--model ", "Model override for agent jobs") .option("--timeout-seconds ", "Timeout seconds for agent jobs") .option("--light-context", "Enable lightweight bootstrap context for agent jobs") diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index fdb45a0960a..e5847f3c164 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -27,7 +27,7 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti .option("-t, --to ", "Recipient number in E.164 used to derive the session key") .option("--session-id ", "Use an explicit session id") .option("--agent ", "Agent id (overrides routing bindings)") - .option("--thinking ", "Thinking level: off | minimal | low | medium | high") + .option("--thinking ", "Thinking level: off | minimal | low | medium | high | xhigh") .option("--verbose ", "Persist agent verbose level for the session") .option( "--channel ", diff --git a/src/commands/daemon-install-helpers.test.ts b/src/commands/daemon-install-helpers.test.ts index 54c5ef7e704..704c193880c 100644 --- a/src/commands/daemon-install-helpers.test.ts +++ b/src/commands/daemon-install-helpers.test.ts @@ -236,7 +236,8 @@ describe("buildGatewayInstallPlan", () => { describe("gatewayInstallErrorHint", () => { it("returns platform-specific hints", () => { - expect(gatewayInstallErrorHint("win32")).toContain("Run as administrator"); + expect(gatewayInstallErrorHint("win32")).toContain("Startup-folder login item"); + expect(gatewayInstallErrorHint("win32")).toContain("elevated PowerShell"); expect(gatewayInstallErrorHint("linux")).toMatch( /(?:openclaw|openclaw)( --profile isolated)? gateway install/, ); diff --git a/src/commands/daemon-install-helpers.ts b/src/commands/daemon-install-helpers.ts index 68b78630ffe..7a3bd42e2fc 100644 --- a/src/commands/daemon-install-helpers.ts +++ b/src/commands/daemon-install-helpers.ts @@ -69,6 +69,6 @@ export async function buildGatewayInstallPlan(params: { export function gatewayInstallErrorHint(platform = process.platform): string { return platform === "win32" - ? "Tip: rerun from an elevated PowerShell (Start β†’ type PowerShell β†’ right-click β†’ Run as administrator) or skip service install." + ? "Tip: native Windows now falls back to a per-user Startup-folder login item when Scheduled Task creation is denied; if install still fails, rerun from an elevated PowerShell or skip service install." : `Tip: rerun \`${formatCliCommand("openclaw gateway install")}\` after fixing the error.`; } diff --git a/src/commands/models/auth.test.ts b/src/commands/models/auth.test.ts index d5e383d775e..e59e7fd021e 100644 --- a/src/commands/models/auth.test.ts +++ b/src/commands/models/auth.test.ts @@ -183,7 +183,7 @@ describe("modelsAuthLoginCommand", () => { "Auth profile: openai-codex:user@example.com (openai-codex/oauth)", ); expect(runtime.log).toHaveBeenCalledWith( - "Default model available: openai-codex/gpt-5.3-codex (use --set-default to apply)", + "Default model available: openai-codex/gpt-5.4 (use --set-default to apply)", ); }); @@ -193,9 +193,9 @@ describe("modelsAuthLoginCommand", () => { await modelsAuthLoginCommand({ provider: "openai-codex", setDefault: true }, runtime); expect(lastUpdatedConfig?.agents?.defaults?.model).toEqual({ - primary: "openai-codex/gpt-5.3-codex", + primary: "openai-codex/gpt-5.4", }); - expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.3-codex"); + expect(runtime.log).toHaveBeenCalledWith("Default model set to openai-codex/gpt-5.4"); }); it("keeps existing plugin error behavior for non built-in providers", async () => { diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index e7ab668ea30..f2e0724b53b 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -13,8 +13,9 @@ const gatewayClientCalls: Array<{ onClose?: (code: number, reason: string) => void; }> = []; const ensureWorkspaceAndSessionsMock = vi.fn(async (..._args: unknown[]) => {}); +const installGatewayDaemonNonInteractiveMock = vi.hoisted(() => vi.fn(async () => {})); let waitForGatewayReachableMock: - | ((params: { url: string; token?: string; password?: string }) => Promise<{ + | ((params: { url: string; token?: string; password?: string; deadlineMs?: number }) => Promise<{ ok: boolean; detail?: string; }>) @@ -59,6 +60,10 @@ vi.mock("./onboard-helpers.js", async (importOriginal) => { }; }); +vi.mock("./onboard-non-interactive/local/daemon-install.js", () => ({ + installGatewayDaemonNonInteractive: installGatewayDaemonNonInteractiveMock, +})); + const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js"); const { resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"); const { resolveConfigPath } = await import("../config/config.js"); @@ -128,6 +133,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { afterEach(() => { waitForGatewayReachableMock = undefined; + installGatewayDaemonNonInteractiveMock.mockClear(); }); it("writes gateway token auth into config", async () => { @@ -343,6 +349,33 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("uses a longer health deadline when daemon install was requested", async () => { + await withStateDir("state-local-daemon-health-", async (stateDir) => { + let capturedDeadlineMs: number | undefined; + waitForGatewayReachableMock = vi.fn(async (params: { deadlineMs?: number }) => { + capturedDeadlineMs = params.deadlineMs; + return { ok: true }; + }); + + await runNonInteractiveOnboarding( + { + nonInteractive: true, + mode: "local", + workspace: path.join(stateDir, "openclaw"), + authChoice: "skip", + skipSkills: true, + skipHealth: false, + installDaemon: true, + gatewayBind: "loopback", + }, + runtime, + ); + + expect(installGatewayDaemonNonInteractiveMock).toHaveBeenCalledTimes(1); + expect(capturedDeadlineMs).toBe(45_000); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local.ts b/src/commands/onboard-non-interactive/local.ts index d6292c015e8..0765eb1a513 100644 --- a/src/commands/onboard-non-interactive/local.ts +++ b/src/commands/onboard-non-interactive/local.ts @@ -19,6 +19,9 @@ import { logNonInteractiveOnboardingJson } from "./local/output.js"; import { applyNonInteractiveSkillsConfig } from "./local/skills-config.js"; import { resolveNonInteractiveWorkspaceDir } from "./local/workspace.js"; +const INSTALL_DAEMON_HEALTH_DEADLINE_MS = 45_000; +const ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS = 15_000; + export async function runNonInteractiveOnboardingLocal(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -107,7 +110,9 @@ export async function runNonInteractiveOnboardingLocal(params: { const probe = await waitForGatewayReachable({ url: links.wsUrl, token: gatewayResult.gatewayToken, - deadlineMs: 15_000, + deadlineMs: opts.installDaemon + ? INSTALL_DAEMON_HEALTH_DEADLINE_MS + : ATTACH_EXISTING_GATEWAY_HEALTH_DEADLINE_MS, }); if (!probe.ok) { const message = [ @@ -118,7 +123,7 @@ export async function runNonInteractiveOnboardingLocal(params: { "Non-interactive local onboarding only waits for an already-running gateway unless you pass --install-daemon.", `Fix: start \`${formatCliCommand("openclaw gateway run")}\`, re-run with \`--install-daemon\`, or use \`--skip-health\`.`, process.platform === "win32" - ? "Native Windows managed gateway install currently uses Scheduled Tasks and may require running PowerShell as Administrator." + ? "Native Windows managed gateway install tries Scheduled Tasks first and falls back to a per-user Startup-folder login item when task creation is denied." : undefined, ] .filter(Boolean) diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 647986a96e0..bd9a05fea10 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -315,6 +315,7 @@ describe("model compat config schema", () => { requiresAssistantAfterToolResult: false, requiresThinkingAsText: false, requiresMistralToolIds: false, + requiresOpenAiAnthropicToolPayload: true, }, }, ], @@ -360,6 +361,33 @@ describe("config strict validation", () => { expect(res.ok).toBe(false); }); + it("accepts documented agents.list[].params overrides", () => { + const res = validateConfigObject({ + agents: { + list: [ + { + id: "main", + model: "anthropic/claude-opus-4-6", + params: { + cacheRetention: "none", + temperature: 0.4, + maxTokens: 8192, + }, + }, + ], + }, + }); + + expect(res.ok).toBe(true); + if (res.ok) { + expect(res.config.agents?.list?.[0]?.params).toEqual({ + cacheRetention: "none", + temperature: 0.4, + maxTokens: 8192, + }); + } + }); + it("flags legacy config entries without auto-migrating", async () => { await withTempHome(async (home) => { await writeOpenClawConfig(home, { diff --git a/src/config/config.schema-regressions.test.ts b/src/config/config.schema-regressions.test.ts index 3e605e06c35..7a6053fd01c 100644 --- a/src/config/config.schema-regressions.test.ts +++ b/src/config/config.schema-regressions.test.ts @@ -211,4 +211,17 @@ describe("config schema regressions", () => { expect(res.ok).toBe(true); }); + + it("accepts discovery.wideArea.domain for unicast DNS-SD", () => { + const res = validateConfigObject({ + discovery: { + wideArea: { + enabled: true, + domain: "openclaw.internal", + }, + }, + }); + + expect(res.ok).toBe(true); + }); }); diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index 965eed0e55d..f74728e360b 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -296,6 +296,7 @@ const TARGET_KEYS = [ "web.reconnect.jitter", "web.reconnect.maxAttempts", "discovery", + "discovery.wideArea.domain", "discovery.wideArea.enabled", "discovery.mdns", "discovery.mdns.mode", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a37302dccf6..215a17d77d8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -292,6 +292,8 @@ export const FIELD_HELP: Record = { "Wide-area discovery configuration group for exposing discovery signals beyond local-link scopes. Enable only in deployments that intentionally aggregate gateway presence across sites.", "discovery.wideArea.enabled": "Enables wide-area discovery signaling when your environment needs non-local gateway discovery. Keep disabled unless cross-network discovery is operationally required.", + "discovery.wideArea.domain": + "Optional unicast DNS-SD domain for wide-area discovery, such as openclaw.internal. Use this when you intentionally publish gateway discovery beyond local mDNS scopes.", "discovery.mdns": "mDNS discovery configuration group for local network advertisement and discovery behavior tuning. Keep minimal mode for routine LAN discovery unless extra metadata is required.", tools: @@ -1483,7 +1485,7 @@ export const FIELD_HELP: Record = { "messages.statusReactions.enabled": "Enable lifecycle status reactions for Telegram. When enabled, the ack reaction becomes the initial 'queued' state and progresses through thinking, tool, done/error automatically. Default: false.", "messages.statusReactions.emojis": - "Override default status reaction emojis. Keys: thinking, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", + "Override default status reaction emojis. Keys: thinking, compacting, tool, coding, web, done, error, stallSoft, stallHard. Must be valid Telegram reaction emojis.", "messages.statusReactions.timing": "Override default timing. Keys: debounceMs (700), stallSoftMs (25000), stallHardMs (60000), doneHoldMs (1500), errorHoldMs (2500).", "messages.inbound.debounceMs": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index c4d84f9d49a..9b1fdb73445 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -654,6 +654,7 @@ export const FIELD_LABELS: Record = { discovery: "Discovery", "discovery.wideArea": "Wide-area Discovery", "discovery.wideArea.enabled": "Wide-area Discovery Enabled", + "discovery.wideArea.domain": "Wide-area Discovery Domain", "discovery.mdns": "mDNS Discovery", canvasHost: "Canvas Host", "canvasHost.enabled": "Canvas Host Enabled", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 54aaa79c846..3d6ecced2ca 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -1,6 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { buildConfigSchema, lookupConfigSchema } from "./schema.js"; import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js"; +import { ToolsSchema } from "./zod-schema.agent-runtime.js"; describe("config schema", () => { type SchemaInput = NonNullable[0]>; @@ -200,6 +201,51 @@ describe("config schema", () => { expect(tags).toContain("performance"); }); + it("accepts web fetch readability and firecrawl config in the runtime zod schema", () => { + const parsed = ToolsSchema.parse({ + web: { + fetch: { + readability: true, + firecrawl: { + enabled: true, + apiKey: "firecrawl-test-key", + baseUrl: "https://api.firecrawl.dev", + onlyMainContent: true, + maxAgeMs: 60_000, + timeoutSeconds: 15, + }, + }, + }, + }); + + expect(parsed?.web?.fetch?.readability).toBe(true); + expect(parsed?.web?.fetch).toMatchObject({ + firecrawl: { + enabled: true, + apiKey: "firecrawl-test-key", + baseUrl: "https://api.firecrawl.dev", + onlyMainContent: true, + maxAgeMs: 60_000, + timeoutSeconds: 15, + }, + }); + }); + + it("rejects unknown keys inside web fetch firecrawl config", () => { + expect(() => + ToolsSchema.parse({ + web: { + fetch: { + firecrawl: { + enabled: true, + nope: true, + }, + }, + }, + }), + ).toThrow(); + }); + it("keeps tags in the allowed taxonomy", () => { const withTags = applyDerivedTags({ "gateway.auth.token": {}, diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index dfe4b74e9b2..6866d6c10c1 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -324,6 +324,88 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); } }); + + it("does not append a duplicate delivery mirror for the same idempotency key", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(2); + + const messageLine = JSON.parse(lines[1]); + expect(messageLine.message.idempotencyKey).toBe("mirror:test-source-message"); + expect(messageLine.message.content[0].text).toBe("Hello from delivery mirror!"); + }); + + it("ignores malformed transcript lines when checking mirror idempotency", async () => { + const sessionId = "test-session-id"; + const sessionKey = "test-session"; + const store = { + [sessionKey]: { + sessionId, + chatType: "direct", + channel: "discord", + }, + }; + fs.writeFileSync(fixture.storePath(), JSON.stringify(store), "utf-8"); + + const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); + fs.writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: "session", + version: 1, + id: sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }), + "{not-json", + JSON.stringify({ + type: "message", + message: { + role: "assistant", + idempotencyKey: "mirror:test-source-message", + content: [{ type: "text", text: "Hello from delivery mirror!" }], + }, + }), + ].join("\n") + "\n", + "utf-8", + ); + + const result = await appendAssistantMessageToSessionTranscript({ + sessionKey, + text: "Hello from delivery mirror!", + idempotencyKey: "mirror:test-source-message", + storePath: fixture.storePath(), + }); + + expect(result.ok).toBe(true); + const lines = fs.readFileSync(sessionFile, "utf-8").trim().split("\n"); + expect(lines.length).toBe(3); + }); }); describe("resolveAndPersistSessionFile", () => { diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 8d924c8feae..720cc3e892e 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -15,6 +15,58 @@ async function resolveRealStorePath(sessionsDir: string): Promise { return fsSync.realpathSync.native(path.join(sessionsDir, "sessions.json")); } +async function createAgentSessionStores( + root: string, + agentIds: string[], +): Promise> { + const storePaths: Record = {}; + for (const agentId of agentIds) { + const sessionsDir = path.join(root, "agents", agentId, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + await fs.writeFile(path.join(sessionsDir, "sessions.json"), "{}", "utf8"); + storePaths[agentId] = await resolveRealStorePath(sessionsDir); + } + return storePaths; +} + +function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenClawConfig { + return { + session: { + store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), + }, + agents: { + list: [{ id: defaultAgentId, default: true }], + }, + }; +} + +function expectTargetsToContainStores( + targets: Array<{ agentId: string; storePath: string }>, + stores: Record, +): void { + expect(targets).toEqual( + expect.arrayContaining( + Object.entries(stores).map(([agentId, storePath]) => ({ + agentId, + storePath, + })), + ), + ); +} + +const discoveryResolvers = [ + { + label: "async", + resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) => + await resolveAllAgentSessionStoreTargets(cfg, { env }), + }, + { + label: "sync", + resolve: async (cfg: OpenClawConfig, env: NodeJS.ProcessEnv) => + resolveAllAgentSessionStoreTargetsSync(cfg, { env }), + }, +] as const; + describe("resolveSessionStoreTargets", () => { it("resolves all configured agent stores", () => { const cfg: OpenClawConfig = { @@ -83,97 +135,39 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("includes discovered on-disk agent stores alongside configured targets", async () => { await withTempHome(async (home) => { const stateDir = path.join(home, ".openclaw"); - const opsSessionsDir = path.join(stateDir, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(stateDir, "agents", "retired", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + const storePaths = await createAgentSessionStores(stateDir, ["ops", "retired"]); const cfg: OpenClawConfig = { agents: { list: [{ id: "ops", default: true }], }, }; - const opsStorePath = await resolveRealStorePath(opsSessionsDir); - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).toEqual( - expect.arrayContaining([ - { - agentId: "ops", - storePath: opsStorePath, - }, - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + expectTargetsToContainStores(targets, storePaths); + expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); }); }); it("discovers retired agent stores under a configured custom session root", async () => { await withTempHome(async (home) => { const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(customRoot, "agents", "retired", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - const opsStorePath = await resolveRealStorePath(opsSessionsDir); - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]); + const cfg = createCustomRootCfg(customRoot); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).toEqual( - expect.arrayContaining([ - { - agentId: "ops", - storePath: opsStorePath, - }, - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - expect(targets.filter((target) => target.storePath === opsStorePath)).toHaveLength(1); + expectTargetsToContainStores(targets, storePaths); + expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); }); }); it("keeps the actual on-disk store path for discovered retired agents", async () => { await withTempHome(async (home) => { const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const retiredSessionsDir = path.join(customRoot, "agents", "Retired Agent", "sessions"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(opsSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); + const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]); + const cfg = createCustomRootCfg(customRoot); const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); @@ -181,7 +175,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { expect.arrayContaining([ expect.objectContaining({ agentId: "retired-agent", - storePath: retiredStorePath, + storePath: storePaths["Retired Agent"], }), ]), ); @@ -223,73 +217,52 @@ describe("resolveAllAgentSessionStoreTargets", () => { }); }); - it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { - await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - await fs.mkdir(customRoot, { recursive: true }); - await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); + for (const resolver of discoveryResolvers) { + it(`skips unreadable or invalid discovery roots when other roots are still readable (${resolver.label})`, async () => { + await withTempHome(async (home) => { + const customRoot = path.join(home, "custom-state"); + await fs.mkdir(customRoot, { recursive: true }); + await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); - const envStateDir = path.join(home, "env-state"); - const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); - const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); + const envStateDir = path.join(home, "env-state"); + const storePaths = await createAgentSessionStores(envStateDir, ["main", "retired"]); + const cfg = createCustomRootCfg(customRoot, "main"); + const env = { + ...process.env, + OPENCLAW_STATE_DIR: envStateDir, + }; - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "main", default: true }], - }, - }; - const env = { - ...process.env, - OPENCLAW_STATE_DIR: envStateDir, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); - - await expect(resolveAllAgentSessionStoreTargets(cfg, { env })).resolves.toEqual( - expect.arrayContaining([ - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - }); - }); - - it("skips symlinked discovered stores under templated agents roots", async () => { - await withTempHome(async (home) => { - if (process.platform === "win32") { - return; - } - const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const leakedFile = path.join(home, "outside.json"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); - await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); - expect(targets).not.toContainEqual({ - agentId: "ops", - storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + await expect(resolver.resolve(cfg, env)).resolves.toEqual( + expect.arrayContaining([ + { + agentId: "retired", + storePath: storePaths.retired, + }, + ]), + ); }); }); - }); + + it(`skips symlinked discovered stores under templated agents roots (${resolver.label})`, async () => { + await withTempHome(async (home) => { + if (process.platform === "win32") { + return; + } + const customRoot = path.join(home, "custom-state"); + const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); + const leakedFile = path.join(home, "outside.json"); + await fs.mkdir(opsSessionsDir, { recursive: true }); + await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); + await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); + + const targets = await resolver.resolve(createCustomRootCfg(customRoot), process.env); + expect(targets).not.toContainEqual({ + agentId: "ops", + storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), + }); + }); + }); + } it("skips discovered directories that only normalize into the default main agent", async () => { await withTempHome(async (home) => { @@ -315,73 +288,3 @@ describe("resolveAllAgentSessionStoreTargets", () => { }); }); }); - -describe("resolveAllAgentSessionStoreTargetsSync", () => { - it("skips unreadable or invalid discovery roots when other roots are still readable", async () => { - await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - await fs.mkdir(customRoot, { recursive: true }); - await fs.writeFile(path.join(customRoot, "agents"), "not-a-directory", "utf8"); - - const envStateDir = path.join(home, "env-state"); - const mainSessionsDir = path.join(envStateDir, "agents", "main", "sessions"); - const retiredSessionsDir = path.join(envStateDir, "agents", "retired", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(retiredSessionsDir, { recursive: true }); - await fs.writeFile(path.join(mainSessionsDir, "sessions.json"), "{}", "utf8"); - await fs.writeFile(path.join(retiredSessionsDir, "sessions.json"), "{}", "utf8"); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "main", default: true }], - }, - }; - const env = { - ...process.env, - OPENCLAW_STATE_DIR: envStateDir, - }; - const retiredStorePath = await resolveRealStorePath(retiredSessionsDir); - - expect(resolveAllAgentSessionStoreTargetsSync(cfg, { env })).toEqual( - expect.arrayContaining([ - { - agentId: "retired", - storePath: retiredStorePath, - }, - ]), - ); - }); - }); - - it("skips symlinked discovered stores under templated agents roots", async () => { - await withTempHome(async (home) => { - if (process.platform === "win32") { - return; - } - const customRoot = path.join(home, "custom-state"); - const opsSessionsDir = path.join(customRoot, "agents", "ops", "sessions"); - const leakedFile = path.join(home, "outside.json"); - await fs.mkdir(opsSessionsDir, { recursive: true }); - await fs.writeFile(leakedFile, JSON.stringify({ leak: { secret: "x" } }), "utf8"); - await fs.symlink(leakedFile, path.join(opsSessionsDir, "sessions.json")); - - const cfg: OpenClawConfig = { - session: { - store: path.join(customRoot, "agents", "{agentId}", "sessions", "sessions.json"), - }, - agents: { - list: [{ id: "ops", default: true }], - }, - }; - - const targets = resolveAllAgentSessionStoreTargetsSync(cfg, { env: process.env }); - expect(targets).not.toContainEqual({ - agentId: "ops", - storePath: expect.stringContaining(path.join("ops", "sessions", "sessions.json")), - }); - }); - }); -}); diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index e6a8044f5c6..aa1890de953 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -135,6 +135,7 @@ export async function appendAssistantMessageToSessionTranscript(params: { sessionKey: string; text?: string; mediaUrls?: string[]; + idempotencyKey?: string; /** Optional override for store path (mostly for tests). */ storePath?: string; }): Promise<{ ok: true; sessionFile: string } | { ok: false; reason: string }> { @@ -179,6 +180,13 @@ export async function appendAssistantMessageToSessionTranscript(params: { await ensureSessionHeader({ sessionFile, sessionId: entry.sessionId }); + if ( + params.idempotencyKey && + (await transcriptHasIdempotencyKey(sessionFile, params.idempotencyKey)) + ) { + return { ok: true, sessionFile }; + } + const sessionManager = SessionManager.open(sessionFile); sessionManager.appendMessage({ role: "assistant", @@ -202,8 +210,34 @@ export async function appendAssistantMessageToSessionTranscript(params: { }, stopReason: "stop", timestamp: Date.now(), + ...(params.idempotencyKey ? { idempotencyKey: params.idempotencyKey } : {}), }); emitSessionTranscriptUpdate(sessionFile); return { ok: true, sessionFile }; } + +async function transcriptHasIdempotencyKey( + transcriptPath: string, + idempotencyKey: string, +): Promise { + try { + const raw = await fs.promises.readFile(transcriptPath, "utf-8"); + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + try { + const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } }; + if (parsed.message?.idempotencyKey === idempotencyKey) { + return true; + } + } catch { + continue; + } + } + } catch { + return false; + } + return false; +} diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 11d1809c86a..c81cf0edbed 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -307,6 +307,8 @@ export type AgentCompactionConfig = { reserveTokensFloor?: number; /** Max share of context window for history during safeguard pruning (0.1–0.9, default 0.5). */ maxHistoryShare?: number; + /** Additional compaction-summary instructions that can preserve language or persona continuity. */ + customInstructions?: string; /** Preserve this many most-recent user/assistant turns verbatim in compaction summary context. */ recentTurnsPreserve?: number; /** Identifier-preservation instruction policy for compaction summaries. */ diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index 39a5ca7da69..002a1200b8b 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -58,6 +58,7 @@ export type StatusReactionsEmojiConfig = { error?: string; stallSoft?: string; stallHard?: string; + compacting?: string; }; export type StatusReactionsTimingConfig = { diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index 1f3d5180b92..bd33a64cf51 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,8 +1,15 @@ import type { CommonChannelMessagingConfig } from "./types.channel-messaging-common.js"; +import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive"; +export type SignalGroupConfig = { + requireMention?: boolean; + tools?: GroupToolPolicyConfig; + toolsBySender?: GroupToolPolicyBySenderConfig; +}; + export type SignalAccountConfig = CommonChannelMessagingConfig & { /** Optional explicit E.164 account for signal-cli. */ account?: string; @@ -24,6 +31,8 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & { ignoreAttachments?: boolean; ignoreStories?: boolean; sendReadReceipts?: boolean; + /** Per-group overrides keyed by Signal group id (or "*"). */ + groups?: Record; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Reaction notification mode (off|own|all|allowlist). Default: own. */ diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index 02148736e2a..dfa7e23e1c1 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -91,6 +91,7 @@ export const AgentDefaultsSchema = z keepRecentTokens: z.number().int().positive().optional(), reserveTokensFloor: z.number().int().nonnegative().optional(), maxHistoryShare: z.number().min(0.1).max(0.9).optional(), + customInstructions: z.string().optional(), identifierPolicy: z .union([z.literal("strict"), z.literal("off"), z.literal("custom")]) .optional(), diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 28c7cfaabed..7a87440a768 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -327,6 +327,18 @@ export const ToolsWebFetchSchema = z cacheTtlMinutes: z.number().nonnegative().optional(), maxRedirects: z.number().int().nonnegative().optional(), userAgent: z.string().optional(), + readability: z.boolean().optional(), + firecrawl: z + .object({ + enabled: z.boolean().optional(), + apiKey: SecretInputSchema.optional().register(sensitive), + baseUrl: z.string().optional(), + onlyMainContent: z.boolean().optional(), + maxAgeMs: z.number().int().nonnegative().optional(), + timeoutSeconds: z.number().int().positive().optional(), + }) + .strict() + .optional(), }) .strict() .optional(); @@ -757,6 +769,7 @@ export const AgentEntrySchema = z .strict() .optional(), sandbox: AgentSandboxSchema, + params: z.record(z.string(), z.unknown()).optional(), tools: AgentToolsSchema, runtime: AgentRuntimeSchema, }) diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 125cdb432ef..ced89bd8512 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -979,6 +979,16 @@ export const SlackConfigSchema = SlackAccountSchema.safeExtend({ validateSlackSigningSecretRequirements(value, ctx); }); +const SignalGroupEntrySchema = z + .object({ + requireMention: z.boolean().optional(), + tools: ToolPolicySchema, + toolsBySender: ToolPolicyBySenderSchema, + }) + .strict(); + +const SignalGroupsSchema = z.record(z.string(), SignalGroupEntrySchema.optional()).optional(); + export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), @@ -1003,6 +1013,7 @@ export const SignalAccountSchemaBase = z defaultTo: z.string().optional(), groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), groupPolicy: GroupPolicySchema.optional().default("allowlist"), + groups: SignalGroupsSchema, historyLimit: z.number().int().min(0).optional(), dmHistoryLimit: z.number().int().min(0).optional(), dms: z.record(z.string(), DmConfigSchema.optional()).optional(), diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index 648caa60f5b..b8bb99b1b14 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -169,6 +169,7 @@ export const MessagesSchema = z error: z.string().optional(), stallSoft: z.string().optional(), stallHard: z.string().optional(), + compacting: z.string().optional(), }) .strict() .optional(), diff --git a/src/config/zod-schema.signal-groups.test.ts b/src/config/zod-schema.signal-groups.test.ts new file mode 100644 index 00000000000..2dcd1ac0676 --- /dev/null +++ b/src/config/zod-schema.signal-groups.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { validateConfigObject } from "./config.js"; + +describe("signal groups schema", () => { + it("accepts top-level Signal groups overrides", () => { + const res = validateConfigObject({ + channels: { + signal: { + groups: { + "*": { + requireMention: false, + }, + "+1234567890": { + requireMention: true, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("accepts per-account Signal groups overrides", () => { + const res = validateConfigObject({ + channels: { + signal: { + accounts: { + primary: { + groups: { + "*": { + requireMention: false, + }, + }, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(true); + }); + + it("rejects unknown keys in Signal groups entries", () => { + const res = validateConfigObject({ + channels: { + signal: { + groups: { + "*": { + requireMention: false, + nope: true, + }, + }, + }, + }, + }); + + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues.some((issue) => issue.path.startsWith("channels.signal.groups"))).toBe( + true, + ); + } + }); +}); diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 1b24eebff4d..0064afddd20 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -596,6 +596,7 @@ export const OpenClawSchema = z wideArea: z .object({ enabled: z.boolean().optional(), + domain: z.string().optional(), }) .strict() .optional(), diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index 2c7eb20a3c6..b245b4b9c94 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -49,7 +49,11 @@ vi.mock("./subagent-followup.js", () => ({ import { countActiveDescendantRuns } from "../../agents/subagent-registry.js"; import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; import { shouldEnqueueCronMainSummary } from "../heartbeat-policy.js"; -import { dispatchCronDelivery } from "./delivery-dispatch.js"; +import { + dispatchCronDelivery, + getCompletedDirectCronDeliveriesCountForTests, + resetCompletedDirectCronDeliveriesForTests, +} from "./delivery-dispatch.js"; import type { DeliveryTargetResolution } from "./delivery-target.js"; import type { RunCronAgentTurnResult } from "./run.js"; import { @@ -84,7 +88,11 @@ function makeWithRunSession() { }); } -function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested?: boolean }) { +function makeBaseParams(overrides: { + synthesizedText?: string; + deliveryRequested?: boolean; + runSessionId?: string; +}) { const resolvedDelivery = makeResolvedDelivery(); return { cfg: {} as never, @@ -98,7 +106,7 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested } as never, agentId: "main", agentSessionKey: "agent:main", - runSessionId: "run-123", + runSessionId: overrides.runSessionId ?? "run-123", runStartedAt: Date.now(), runEndedAt: Date.now(), timeoutMs: 30_000, @@ -126,6 +134,7 @@ function makeBaseParams(overrides: { synthesizedText?: string; deliveryRequested describe("dispatchCronDelivery β€” double-announce guard", () => { beforeEach(() => { vi.clearAllMocks(); + resetCompletedDirectCronDeliveriesForTests(); vi.mocked(countActiveDescendantRuns).mockReturnValue(0); vi.mocked(expectsSubagentFollowup).mockReturnValue(false); vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); @@ -278,6 +287,38 @@ describe("dispatchCronDelivery β€” double-announce guard", () => { expect(deliverOutboundPayloads).toHaveBeenCalledTimes(2); }); + it("keeps direct announce delivery idempotent across replay for the same run session", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + const params = makeBaseParams({ synthesizedText: "Replay-safe cron update." }); + const first = await dispatchCronDelivery(params); + const second = await dispatchCronDelivery(params); + + expect(first.delivered).toBe(true); + expect(second.delivered).toBe(true); + expect(second.deliveryAttempted).toBe(true); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + }); + + it("prunes the completed-delivery cache back to the entry cap", async () => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false); + vi.mocked(deliverOutboundPayloads).mockResolvedValue([{ ok: true } as never]); + + for (let i = 0; i < 2003; i += 1) { + const params = makeBaseParams({ + synthesizedText: `Replay-safe cron update ${i}.`, + runSessionId: `run-${i}`, + }); + const state = await dispatchCronDelivery(params); + expect(state.delivered).toBe(true); + } + + expect(getCompletedDirectCronDeliveriesCountForTests()).toBe(2000); + }); + it("does not retry permanent direct announce failures", async () => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); vi.mocked(countActiveDescendantRuns).mockReturnValue(0); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index a5dc0190b72..6ddddf20669 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -5,7 +5,10 @@ import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-de import type { OpenClawConfig } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { sleepWithAbort } from "../../infra/backoff.js"; -import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js"; +import { + deliverOutboundPayloads, + type OutboundDeliveryResult, +} from "../../infra/outbound/deliver.js"; import { resolveAgentOutboundIdentity } from "../../infra/outbound/identity.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { logWarn } from "../../logger.js"; @@ -131,6 +134,91 @@ const PERMANENT_DIRECT_CRON_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ /outbound not configured for channel/i, ]; +type CompletedDirectCronDelivery = { + ts: number; + results: OutboundDeliveryResult[]; +}; + +const COMPLETED_DIRECT_CRON_DELIVERIES = new Map(); + +function cloneDeliveryResults( + results: readonly OutboundDeliveryResult[], +): OutboundDeliveryResult[] { + return results.map((result) => ({ + ...result, + ...(result.meta ? { meta: { ...result.meta } } : {}), + })); +} + +function pruneCompletedDirectCronDeliveries(now: number) { + const ttlMs = process.env.OPENCLAW_TEST_FAST === "1" ? 60_000 : 24 * 60 * 60 * 1000; + for (const [key, entry] of COMPLETED_DIRECT_CRON_DELIVERIES) { + if (now - entry.ts >= ttlMs) { + COMPLETED_DIRECT_CRON_DELIVERIES.delete(key); + } + } + const maxEntries = 2000; + if (COMPLETED_DIRECT_CRON_DELIVERIES.size <= maxEntries) { + return; + } + const entries = [...COMPLETED_DIRECT_CRON_DELIVERIES.entries()].toSorted( + (a, b) => a[1].ts - b[1].ts, + ); + const toDelete = COMPLETED_DIRECT_CRON_DELIVERIES.size - maxEntries; + for (let i = 0; i < toDelete; i += 1) { + const oldest = entries[i]; + if (!oldest) { + break; + } + COMPLETED_DIRECT_CRON_DELIVERIES.delete(oldest[0]); + } +} + +function rememberCompletedDirectCronDelivery( + idempotencyKey: string, + results: readonly OutboundDeliveryResult[], +) { + const now = Date.now(); + COMPLETED_DIRECT_CRON_DELIVERIES.set(idempotencyKey, { + ts: now, + results: cloneDeliveryResults(results), + }); + pruneCompletedDirectCronDeliveries(now); +} + +function getCompletedDirectCronDelivery( + idempotencyKey: string, +): OutboundDeliveryResult[] | undefined { + const now = Date.now(); + pruneCompletedDirectCronDeliveries(now); + const cached = COMPLETED_DIRECT_CRON_DELIVERIES.get(idempotencyKey); + if (!cached) { + return undefined; + } + return cloneDeliveryResults(cached.results); +} + +function buildDirectCronDeliveryIdempotencyKey(params: { + runSessionId: string; + delivery: SuccessfulDeliveryTarget; +}): string { + const threadId = + params.delivery.threadId == null || params.delivery.threadId === "" + ? "" + : String(params.delivery.threadId); + const accountId = params.delivery.accountId?.trim() ?? ""; + const normalizedTo = normalizeDeliveryTarget(params.delivery.channel, params.delivery.to); + return `cron-direct-delivery:v1:${params.runSessionId}:${params.delivery.channel}:${accountId}:${normalizedTo}:${threadId}`; +} + +export function resetCompletedDirectCronDeliveriesForTests() { + COMPLETED_DIRECT_CRON_DELIVERIES.clear(); +} + +export function getCompletedDirectCronDeliveriesCountForTests(): number { + return COMPLETED_DIRECT_CRON_DELIVERIES.size; +} + function summarizeDirectCronDeliveryError(error: unknown): string { if (error instanceof Error) { return error.message || "error"; @@ -221,6 +309,10 @@ export async function dispatchCronDelivery( options?: { retryTransient?: boolean }, ): Promise => { const identity = resolveAgentOutboundIdentity(params.cfgWithAgentDefaults, params.agentId); + const deliveryIdempotencyKey = buildDirectCronDeliveryIdempotencyKey({ + runSessionId: params.runSessionId, + delivery, + }); try { const payloadsForDelivery = deliveryPayloads.length > 0 @@ -240,6 +332,12 @@ export async function dispatchCronDelivery( }); } deliveryAttempted = true; + const cachedResults = getCompletedDirectCronDelivery(deliveryIdempotencyKey); + if (cachedResults) { + // Cached entries are only recorded after a successful non-empty delivery. + delivered = true; + return null; + } const deliverySession = buildOutboundSessionContext({ cfg: params.cfgWithAgentDefaults, agentId: params.agentId, @@ -273,6 +371,9 @@ export async function dispatchCronDelivery( }) : await runDelivery(); delivered = deliveryResults.length > 0; + if (delivered) { + rememberCompletedDirectCronDelivery(deliveryIdempotencyKey, deliveryResults); + } return null; } catch (err) { if (!params.deliveryBestEffort) { diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index c027c8d553f..69751e4dfdb 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -360,13 +360,23 @@ type ManualRunDisposition = | Extract | { ok: true; runnable: true }; +type ManualRunPreflightResult = + | { ok: false } + | Extract + | { + ok: true; + runnable: true; + job: CronJob; + now: number; + }; + let nextManualRunId = 1; -async function inspectManualRunDisposition( +async function inspectManualRunPreflight( state: CronServiceState, id: string, mode?: "due" | "force", -): Promise { +): Promise { return await locked(state, async () => { warnIfDisabled(state, "run"); await ensureLoaded(state, { skipRecompute: true }); @@ -383,46 +393,60 @@ async function inspectManualRunDisposition( if (!due) { return { ok: true, ran: false, reason: "not-due" as const }; } - return { ok: true, runnable: true } as const; + return { ok: true, runnable: true, job, now } as const; }); } +async function inspectManualRunDisposition( + state: CronServiceState, + id: string, + mode?: "due" | "force", +): Promise { + const result = await inspectManualRunPreflight(state, id, mode); + if (!result.ok) { + return result; + } + if ("reason" in result) { + return result; + } + return { ok: true, runnable: true } as const; +} + async function prepareManualRun( state: CronServiceState, id: string, mode?: "due" | "force", ): Promise { + const preflight = await inspectManualRunPreflight(state, id, mode); + if (!preflight.ok) { + return preflight; + } + if ("reason" in preflight) { + return { + ok: true, + ran: false, + reason: preflight.reason, + } as const; + } return await locked(state, async () => { - warnIfDisabled(state, "run"); - await ensureLoaded(state, { skipRecompute: true }); - // Normalize job tick state (clears stale runningAtMs markers) before - // checking if already running, so a stale marker from a crashed Phase-1 - // persist does not block manual triggers for up to STUCK_RUN_MS (#17554). - recomputeNextRunsForMaintenance(state); + // Reserve this run under lock, then execute outside lock so read ops + // (`list`, `status`) stay responsive while the run is in progress. const job = findJobOrThrow(state, id); if (typeof job.state.runningAtMs === "number") { return { ok: true, ran: false, reason: "already-running" as const }; } - const now = state.deps.nowMs(); - const due = isJobDue(job, now, { forced: mode === "force" }); - if (!due) { - return { ok: true, ran: false, reason: "not-due" as const }; - } - - // Reserve this run under lock, then execute outside lock so read ops - // (`list`, `status`) stay responsive while the run is in progress. - job.state.runningAtMs = now; + job.state.runningAtMs = preflight.now; job.state.lastError = undefined; // Persist the running marker before releasing lock so timer ticks that // force-reload from disk cannot start the same job concurrently. await persist(state); - emit(state, { jobId: job.id, action: "started", runAtMs: now }); + emit(state, { jobId: job.id, action: "started", runAtMs: preflight.now }); const executionJob = JSON.parse(JSON.stringify(job)) as CronJob; return { ok: true, ran: true, jobId: job.id, - startedAt: now, + startedAt: preflight.now, executionJob, } as const; }); diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 3acd239afe1..ba43715ba28 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -250,7 +250,7 @@ describe("launchd install", () => { }; } - it("enables service before bootstrap (clears persisted disabled state)", async () => { + it("enables service before bootstrap without self-restarting the fresh agent", async () => { const env = createDefaultLaunchdEnv(); await installLaunchAgent({ env, @@ -269,9 +269,13 @@ describe("launchd install", () => { const bootstrapIndex = state.launchctlCalls.findIndex( (c) => c[0] === "bootstrap" && c[1] === domain && c[2] === plistPath, ); + const installKickstartIndex = state.launchctlCalls.findIndex( + (c) => c[0] === "kickstart" && c[2] === serviceId, + ); expect(enableIndex).toBeGreaterThanOrEqual(0); expect(bootstrapIndex).toBeGreaterThanOrEqual(0); expect(enableIndex).toBeLessThan(bootstrapIndex); + expect(installKickstartIndex).toBe(-1); }); it("writes TMPDIR to LaunchAgent environment when provided", async () => { diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 68ae1b43edd..0e6d8610931 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -431,7 +431,9 @@ export async function installLaunchAgent({ } throw new Error(`launchctl bootstrap failed: ${detail}`); } - await execLaunchctl(["kickstart", "-k", `${domain}/${label}`]); + // `bootstrap` already loads RunAtLoad agents. Avoid `kickstart -k` here: + // on slow macOS guests it SIGTERMs the freshly booted gateway and pushes the + // real listener startup past onboarding's health deadline. // Ensure we don't end up writing to a clack spinner line (wizards show progress without a newline). writeFormattedLines( diff --git a/src/daemon/schtasks-exec.test.ts b/src/daemon/schtasks-exec.test.ts new file mode 100644 index 00000000000..52edb573ea7 --- /dev/null +++ b/src/daemon/schtasks-exec.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const runCommandWithTimeout = vi.hoisted(() => vi.fn()); + +vi.mock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), +})); + +const { execSchtasks } = await import("./schtasks-exec.js"); + +beforeEach(() => { + runCommandWithTimeout.mockReset(); +}); + +describe("execSchtasks", () => { + it("runs schtasks with bounded timeouts", async () => { + runCommandWithTimeout.mockResolvedValue({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }); + + await expect(execSchtasks(["/Query"])).resolves.toEqual({ + stdout: "ok", + stderr: "", + code: 0, + }); + expect(runCommandWithTimeout).toHaveBeenCalledWith(["schtasks", "/Query"], { + timeoutMs: 15_000, + noOutputTimeoutMs: 5_000, + }); + }); + + it("maps a timeout into a non-zero schtasks result", async () => { + runCommandWithTimeout.mockResolvedValue({ + stdout: "", + stderr: "", + code: null, + signal: "SIGTERM", + killed: true, + termination: "timeout", + }); + + await expect(execSchtasks(["/Create"])).resolves.toEqual({ + stdout: "", + stderr: "schtasks timed out after 15000ms", + code: 124, + }); + }); +}); diff --git a/src/daemon/schtasks-exec.ts b/src/daemon/schtasks-exec.ts index e4344d3cd5d..cf27d927341 100644 --- a/src/daemon/schtasks-exec.ts +++ b/src/daemon/schtasks-exec.ts @@ -1,7 +1,24 @@ -import { execFileUtf8 } from "./exec-file.js"; +import { runCommandWithTimeout } from "../process/exec.js"; + +const SCHTASKS_TIMEOUT_MS = 15_000; +const SCHTASKS_NO_OUTPUT_TIMEOUT_MS = 5_000; export async function execSchtasks( args: string[], ): Promise<{ stdout: string; stderr: string; code: number }> { - return await execFileUtf8("schtasks", args, { windowsHide: true }); + const result = await runCommandWithTimeout(["schtasks", ...args], { + timeoutMs: SCHTASKS_TIMEOUT_MS, + noOutputTimeoutMs: SCHTASKS_NO_OUTPUT_TIMEOUT_MS, + }); + const timeoutDetail = + result.termination === "timeout" + ? `schtasks timed out after ${SCHTASKS_TIMEOUT_MS}ms` + : result.termination === "no-output-timeout" + ? `schtasks produced no output for ${SCHTASKS_NO_OUTPUT_TIMEOUT_MS}ms` + : ""; + return { + stdout: result.stdout, + stderr: result.stderr || timeoutDetail, + code: typeof result.code === "number" ? result.code : result.killed ? 124 : 1, + }; } diff --git a/src/daemon/schtasks.startup-fallback.test.ts b/src/daemon/schtasks.startup-fallback.test.ts index 0bf27dc1028..8b26a98e4ed 100644 --- a/src/daemon/schtasks.startup-fallback.test.ts +++ b/src/daemon/schtasks.startup-fallback.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { quoteCmdScriptArg } from "./cmd-argv.js"; const schtasksResponses = vi.hoisted( () => [] as Array<{ code: number; stdout: string; stderr: string }>, @@ -10,7 +11,8 @@ const schtasksResponses = vi.hoisted( const schtasksCalls = vi.hoisted(() => [] as string[][]); const inspectPortUsage = vi.hoisted(() => vi.fn()); const killProcessTree = vi.hoisted(() => vi.fn()); -const runCommandWithTimeout = vi.hoisted(() => vi.fn()); +const childUnref = vi.hoisted(() => vi.fn()); +const spawn = vi.hoisted(() => vi.fn(() => ({ unref: childUnref }))); vi.mock("./schtasks-exec.js", () => ({ execSchtasks: async (argv: string[]) => { @@ -27,8 +29,8 @@ vi.mock("../process/kill-tree.js", () => ({ killProcessTree: (...args: unknown[]) => killProcessTree(...args), })); -vi.mock("../process/exec.js", () => ({ - runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeout(...args), +vi.mock("node:child_process", () => ({ + spawn, })); const { @@ -73,15 +75,8 @@ beforeEach(() => { schtasksCalls.length = 0; inspectPortUsage.mockReset(); killProcessTree.mockReset(); - runCommandWithTimeout.mockReset(); - runCommandWithTimeout.mockResolvedValue({ - stdout: "", - stderr: "", - code: 0, - signal: null, - killed: false, - termination: "exit", - }); + spawn.mockClear(); + childUnref.mockClear(); }); afterEach(() => { @@ -114,14 +109,40 @@ describe("Windows startup fallback", () => { expect(result.scriptPath).toBe(resolveTaskScriptPath(env)); expect(startupScript).toContain('start "" /min cmd.exe /d /c'); expect(startupScript).toContain("gateway.cmd"); - expect(runCommandWithTimeout).toHaveBeenCalledWith( - ["cmd.exe", "/d", "/s", "/c", startupEntryPath], - expect.objectContaining({ timeoutMs: 3000, windowsVerbatimArguments: true }), + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), ); + expect(childUnref).toHaveBeenCalled(); expect(printed).toContain("Installed Windows login item"); }); }); + it("falls back to a Startup-folder launcher when schtasks create hangs", async () => { + await withWindowsEnv(async ({ env }) => { + schtasksResponses.push( + { code: 0, stdout: "", stderr: "" }, + { code: 124, stdout: "", stderr: "schtasks timed out after 15000ms" }, + ); + + const stdout = new PassThrough(); + await installScheduledTask({ + env, + stdout, + programArguments: ["node", "gateway.js", "--port", "18789"], + environment: { OPENCLAW_GATEWAY_PORT: "18789" }, + }); + + await expect(fs.access(resolveStartupEntryPath(env))).resolves.toBeUndefined(); + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), + ); + }); + }); + it("treats an installed Startup-folder launcher as loaded", async () => { await withWindowsEnv(async ({ env }) => { schtasksResponses.push( @@ -179,7 +200,11 @@ describe("Windows startup fallback", () => { outcome: "completed", }); expect(killProcessTree).toHaveBeenCalledWith(5151, { graceMs: 300 }); - expect(runCommandWithTimeout).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", quoteCmdScriptArg(resolveTaskScriptPath(env))], + expect.objectContaining({ detached: true, stdio: "ignore", windowsHide: true }), + ); }); }); }); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 8e5b60786c5..2c74cf26a61 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -1,7 +1,7 @@ +import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { inspectPortUsage } from "../infra/ports.js"; -import { runCommandWithTimeout } from "../process/exec.js"; import { killProcessTree } from "../process/kill-tree.js"; import { parseCmdScriptCommandLine, quoteCmdScriptArg } from "./cmd-argv.js"; import { assertNoCmdLineBreak, parseCmdSetAssignment, renderCmdSetAssignment } from "./cmd-set.js"; @@ -30,6 +30,15 @@ function resolveTaskName(env: GatewayServiceEnv): string { return resolveGatewayWindowsTaskName(env.OPENCLAW_PROFILE); } +function shouldFallbackToStartupEntry(params: { code: number; detail: string }): boolean { + return ( + /access is denied/i.test(params.detail) || + params.code === 124 || + /schtasks timed out/i.test(params.detail) || + /schtasks produced no output/i.test(params.detail) + ); +} + export function resolveTaskScriptPath(env: GatewayServiceEnv): string { const override = env.OPENCLAW_TASK_SCRIPT?.trim(); if (override) { @@ -284,12 +293,13 @@ async function isRegisteredScheduledTask(env: GatewayServiceEnv): Promise { - const startupEntryPath = resolveStartupEntryPath(env); - await runCommandWithTimeout(["cmd.exe", "/d", "/s", "/c", startupEntryPath], { - timeoutMs: 3000, - windowsVerbatimArguments: true, +function launchFallbackTaskScript(scriptPath: string): void { + const child = spawn("cmd.exe", ["/d", "/s", "/c", quoteCmdScriptArg(scriptPath)], { + detached: true, + stdio: "ignore", + windowsHide: true, }); + child.unref(); } function resolveConfiguredGatewayPort(env: GatewayServiceEnv): number | null { @@ -346,7 +356,7 @@ async function restartStartupEntry( if (typeof runtime.pid === "number" && runtime.pid > 0) { killProcessTree(runtime.pid, { graceMs: 300 }); } - await launchStartupEntry(env); + launchFallbackTaskScript(resolveTaskScriptPath(env)); stdout.write(`${formatLine("Restarted Windows login item", resolveTaskName(env))}\n`); return { outcome: "completed" }; } @@ -394,12 +404,12 @@ export async function installScheduledTask({ } if (create.code !== 0) { const detail = create.stderr || create.stdout; - if (/access is denied/i.test(detail)) { + if (shouldFallbackToStartupEntry({ code: create.code, detail })) { const startupEntryPath = resolveStartupEntryPath(env); await fs.mkdir(path.dirname(startupEntryPath), { recursive: true }); const launcher = buildStartupLauncherScript({ description: taskDescription, scriptPath }); await fs.writeFile(startupEntryPath, launcher, "utf8"); - await launchStartupEntry(env); + launchFallbackTaskScript(scriptPath); writeFormattedLines( stdout, [ diff --git a/src/discord/monitor/gateway-plugin.ts b/src/discord/monitor/gateway-plugin.ts index c86b6259c5e..b4030bcb386 100644 --- a/src/discord/monitor/gateway-plugin.ts +++ b/src/discord/monitor/gateway-plugin.ts @@ -7,6 +7,18 @@ import type { DiscordAccountConfig } from "../../config/types.js"; import { danger } from "../../globals.js"; import type { RuntimeEnv } from "../../runtime.js"; +const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot"; +const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/"; + +type DiscordGatewayMetadataResponse = Pick; +type DiscordGatewayFetchInit = Record & { + headers?: Record; +}; +type DiscordGatewayFetch = ( + input: string, + init?: DiscordGatewayFetchInit, +) => Promise; + export function resolveDiscordGatewayIntents( intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig, ): number { @@ -27,6 +39,138 @@ export function resolveDiscordGatewayIntents( return intents; } +function summarizeGatewayResponseBody(body: string): string { + const normalized = body.trim().replace(/\s+/g, " "); + if (!normalized) { + return ""; + } + return normalized.slice(0, 240); +} + +function isTransientDiscordGatewayResponse(status: number, body: string): boolean { + if (status >= 500) { + return true; + } + const normalized = body.toLowerCase(); + return ( + normalized.includes("upstream connect error") || + normalized.includes("disconnect/reset before headers") || + normalized.includes("reset reason:") + ); +} + +function createGatewayMetadataError(params: { + detail: string; + transient: boolean; + cause?: unknown; +}): Error { + if (params.transient) { + return new Error("Failed to get gateway information from Discord: fetch failed", { + cause: params.cause ?? new Error(params.detail), + }); + } + return new Error(`Failed to get gateway information from Discord: ${params.detail}`, { + cause: params.cause, + }); +} + +async function fetchDiscordGatewayInfo(params: { + token: string; + fetchImpl: DiscordGatewayFetch; + fetchInit?: DiscordGatewayFetchInit; +}): Promise { + let response: DiscordGatewayMetadataResponse; + try { + response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, { + ...params.fetchInit, + headers: { + ...params.fetchInit?.headers, + Authorization: `Bot ${params.token}`, + }, + }); + } catch (error) { + throw createGatewayMetadataError({ + detail: error instanceof Error ? error.message : String(error), + transient: true, + cause: error, + }); + } + + let body: string; + try { + body = await response.text(); + } catch (error) { + throw createGatewayMetadataError({ + detail: error instanceof Error ? error.message : String(error), + transient: true, + cause: error, + }); + } + const summary = summarizeGatewayResponseBody(body); + const transient = isTransientDiscordGatewayResponse(response.status, body); + + if (!response.ok) { + throw createGatewayMetadataError({ + detail: `Discord API /gateway/bot failed (${response.status}): ${summary}`, + transient, + }); + } + + try { + const parsed = JSON.parse(body) as Partial; + return { + ...parsed, + url: + typeof parsed.url === "string" && parsed.url.trim() + ? parsed.url + : DEFAULT_DISCORD_GATEWAY_URL, + } as APIGatewayBotInfo; + } catch (error) { + throw createGatewayMetadataError({ + detail: `Discord API /gateway/bot returned invalid JSON: ${summary}`, + transient, + cause: error, + }); + } +} + +function createGatewayPlugin(params: { + options: { + reconnect: { maxAttempts: number }; + intents: number; + autoInteractions: boolean; + }; + fetchImpl: DiscordGatewayFetch; + fetchInit?: DiscordGatewayFetchInit; + wsAgent?: HttpsProxyAgent; +}): GatewayPlugin { + class SafeGatewayPlugin extends GatewayPlugin { + constructor() { + super(params.options); + } + + override async registerClient(client: Parameters[0]) { + if (!this.gatewayInfo) { + this.gatewayInfo = await fetchDiscordGatewayInfo({ + token: client.options.token, + fetchImpl: params.fetchImpl, + fetchInit: params.fetchInit, + }); + } + return super.registerClient(client); + } + + override createWebSocket(url: string) { + if (!params.wsAgent) { + return super.createWebSocket(url); + } + return new WebSocket(url, { agent: params.wsAgent }); + } + } + + return new SafeGatewayPlugin(); +} + export function createDiscordGatewayPlugin(params: { discordConfig: DiscordAccountConfig; runtime: RuntimeEnv; @@ -40,7 +184,10 @@ export function createDiscordGatewayPlugin(params: { }; if (!proxy) { - return new GatewayPlugin(options); + return createGatewayPlugin({ + options, + fetchImpl: (input, init) => fetch(input, init as RequestInit), + }); } try { @@ -49,39 +196,17 @@ export function createDiscordGatewayPlugin(params: { params.runtime.log?.("discord: gateway proxy enabled"); - class ProxyGatewayPlugin extends GatewayPlugin { - constructor() { - super(options); - } - - override async registerClient(client: Parameters[0]) { - if (!this.gatewayInfo) { - try { - const response = await undiciFetch("https://discord.com/api/v10/gateway/bot", { - headers: { - Authorization: `Bot ${client.options.token}`, - }, - dispatcher: fetchAgent, - } as Record); - this.gatewayInfo = (await response.json()) as APIGatewayBotInfo; - } catch (error) { - throw new Error( - `Failed to get gateway information from Discord: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - } - return super.registerClient(client); - } - - override createWebSocket(url: string) { - return new WebSocket(url, { agent: wsAgent }); - } - } - - return new ProxyGatewayPlugin(); + return createGatewayPlugin({ + options, + fetchImpl: (input, init) => undiciFetch(input, init), + fetchInit: { dispatcher: fetchAgent }, + wsAgent, + }); } catch (err) { params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`)); - return new GatewayPlugin(options); + return createGatewayPlugin({ + options, + fetchImpl: (input, init) => fetch(input, init as RequestInit), + }); } } diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 8b059d00f39..96c9a65df9c 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -47,15 +47,19 @@ type DispatchInboundParams = { onReasoningStream?: () => Promise | void; onReasoningEnd?: () => Promise | void; onToolStart?: (payload: { name?: string }) => Promise | void; + onCompactionStart?: () => Promise | void; + onCompactionEnd?: () => Promise | void; onPartialReply?: (payload: { text?: string }) => Promise | void; onAssistantMessageStart?: () => Promise | void; }; }; -const dispatchInboundMessage = vi.fn(async (_params?: DispatchInboundParams) => ({ - queuedFinal: false, - counts: { final: 0, tool: 0, block: 0 }, -})); -const recordInboundSession = vi.fn(async () => {}); +const dispatchInboundMessage = vi.hoisted(() => + vi.fn(async (_params?: DispatchInboundParams) => ({ + queuedFinal: false, + counts: { final: 0, tool: 0, block: 0 }, + })), +); +const recordInboundSession = vi.hoisted(() => vi.fn(async () => {})); const configSessionsMocks = vi.hoisted(() => ({ readSessionUpdatedAt: vi.fn(() => undefined), resolveStorePath: vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json"), @@ -346,6 +350,39 @@ describe("processDiscordMessage ack reactions", () => { expect(emojis).toContain("🏁"); }); + it("shows compacting reaction during auto-compaction and resumes thinking", async () => { + vi.useFakeTimers(); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.replyOptions?.onCompactionStart?.(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + await params?.replyOptions?.onCompactionEnd?.(); + await new Promise((resolve) => setTimeout(resolve, 1_000)); + return createNoQueuedDispatchResult(); + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { + ackReaction: "πŸ‘€", + statusReactions: { + timing: { debounceMs: 0 }, + }, + }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + const runPromise = processDiscordMessage(ctx as any); + await vi.advanceTimersByTimeAsync(2_500); + await vi.runAllTimersAsync(); + await runPromise; + + const emojis = getReactionEmojis(); + expect(emojis).toContain(DEFAULT_EMOJIS.compacting); + expect(emojis).toContain(DEFAULT_EMOJIS.thinking); + }); + it("clears status reactions when dispatch aborts and removeAckAfterReply is enabled", async () => { const abortController = new AbortController(); dispatchInboundMessage.mockImplementationOnce(async () => { diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index ea64b37f98e..36978628b7a 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -769,6 +769,19 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } await statusReactions.setTool(payload.name); }, + onCompactionStart: async () => { + if (isProcessAborted(abortSignal)) { + return; + } + await statusReactions.setCompacting(); + }, + onCompactionEnd: async () => { + if (isProcessAborted(abortSignal)) { + return; + } + statusReactions.cancelPending(); + await statusReactions.setThinking(); + }, }, }); if (isProcessAborted(abortSignal)) { diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index 4d43469e2e4..0b45fd2a2e7 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -4,6 +4,7 @@ const { GatewayIntents, baseRegisterClientSpy, GatewayPlugin, + globalFetchMock, HttpsProxyAgent, getLastAgent, restProxyAgentSpy, @@ -17,6 +18,7 @@ const { const undiciProxyAgentSpy = vi.fn(); const restProxyAgentSpy = vi.fn(); const undiciFetchMock = vi.fn(); + const globalFetchMock = vi.fn(); const baseRegisterClientSpy = vi.fn(); const webSocketSpy = vi.fn(); @@ -60,6 +62,7 @@ const { baseRegisterClientSpy, GatewayIntents, GatewayPlugin, + globalFetchMock, HttpsProxyAgent, getLastAgent: () => HttpsProxyAgent.lastCreated, restProxyAgentSpy, @@ -121,7 +124,9 @@ describe("createDiscordGatewayPlugin", () => { } beforeEach(() => { + vi.stubGlobal("fetch", globalFetchMock); baseRegisterClientSpy.mockClear(); + globalFetchMock.mockClear(); restProxyAgentSpy.mockClear(); undiciFetchMock.mockClear(); undiciProxyAgentSpy.mockClear(); @@ -130,6 +135,60 @@ describe("createDiscordGatewayPlugin", () => { resetLastAgent(); }); + it("uses safe gateway metadata lookup without proxy", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }); + + expect(globalFetchMock).toHaveBeenCalledWith( + "https://discord.com/api/v10/gateway/bot", + expect.objectContaining({ + headers: { Authorization: "Bot token-123" }, + }), + ); + expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); + }); + + it("maps plain-text Discord 503 responses to fetch failed", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: false, + status: 503, + text: async () => + "upstream connect error or disconnect/reset before headers. reset reason: overflow", + } as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await expect( + ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }), + ).rejects.toThrow("Failed to get gateway information from Discord: fetch failed"); + expect(baseRegisterClientSpy).not.toHaveBeenCalled(); + }); + it("uses proxy agent for gateway WebSocket when configured", async () => { const runtime = createRuntime(); @@ -161,7 +220,7 @@ describe("createDiscordGatewayPlugin", () => { runtime, }); - expect(Object.getPrototypeOf(plugin)).toBe(GatewayPlugin.prototype); + expect(Object.getPrototypeOf(plugin)).not.toBe(GatewayPlugin.prototype); expect(runtime.error).toHaveBeenCalled(); expect(runtime.log).not.toHaveBeenCalled(); }); @@ -169,7 +228,9 @@ describe("createDiscordGatewayPlugin", () => { it("uses proxy fetch for gateway metadata lookup before registering", async () => { const runtime = createRuntime(); undiciFetchMock.mockResolvedValue({ - json: async () => ({ url: "wss://gateway.discord.gg" }), + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), } as Response); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, @@ -194,4 +255,30 @@ describe("createDiscordGatewayPlugin", () => { ); expect(baseRegisterClientSpy).toHaveBeenCalledTimes(1); }); + + it("maps body read failures to fetch failed", async () => { + const runtime = createRuntime(); + globalFetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => { + throw new Error("body stream closed"); + }, + } as unknown as Response); + const plugin = createDiscordGatewayPlugin({ + discordConfig: {}, + runtime, + }); + + await expect( + ( + plugin as unknown as { + registerClient: (client: { options: { token: string } }) => Promise; + } + ).registerClient({ + options: { token: "token-123" }, + }), + ).rejects.toThrow("Failed to get gateway information from Discord: fetch failed"); + expect(baseRegisterClientSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index 6890e7d55a8..8d5eec70ed0 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -205,6 +205,18 @@ describe("docker-setup.sh", () => { expect(identityDirStat.isDirectory()).toBe(true); }); + it("writes OPENCLAW_TZ into .env when given a real IANA timezone", async () => { + const activeSandbox = requireSandbox(sandbox); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_TZ: "Asia/Shanghai", + }); + + expect(result.status).toBe(0); + const envFile = await readFile(join(activeSandbox.rootDir, ".env"), "utf8"); + expect(envFile).toContain("OPENCLAW_TZ=Asia/Shanghai"); + }); + it("precreates agent data dirs to avoid EACCES in container", async () => { const activeSandbox = requireSandbox(sandbox); const configDir = join(activeSandbox.rootDir, "config-agent-dirs"); @@ -411,6 +423,17 @@ describe("docker-setup.sh", () => { expect(result.stderr).toContain("OPENCLAW_HOME_VOLUME must match"); }); + it("rejects OPENCLAW_TZ values that are not present in zoneinfo", async () => { + const activeSandbox = requireSandbox(sandbox); + + const result = runDockerSetup(activeSandbox, { + OPENCLAW_TZ: "Nope/Bad", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("OPENCLAW_TZ must match a timezone in /usr/share/zoneinfo"); + }); + it("avoids associative arrays so the script remains Bash 3.2-compatible", async () => { const script = await readFile(join(repoRoot, "docker-setup.sh"), "utf8"); expect(script).not.toMatch(/^\s*declare -A\b/m); @@ -455,4 +478,9 @@ describe("docker-setup.sh", () => { 2, ); }); + + it("keeps docker-compose timezone env defaults aligned across services", async () => { + const compose = await readFile(join(repoRoot, "docker-compose.yml"), "utf8"); + expect(compose.match(/TZ: \$\{OPENCLAW_TZ:-UTC\}/g)).toHaveLength(2); + }); }); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 87590e58d49..e4d8d28f562 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -14,6 +14,7 @@ let lastClientOptions: { password?: string; tlsFingerprint?: string; scopes?: string[]; + deviceIdentity?: unknown; onHelloOk?: (hello: { features?: { methods?: string[] } }) => void | Promise; onClose?: (code: number, reason: string) => void; } | null = null; @@ -197,6 +198,19 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); + it("does not attach device identity for local loopback shared-token auth", async () => { + setLocalLoopbackGatewayConfig(); + + await callGateway({ + method: "health", + token: "explicit-token", + }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + }); + it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 31d11ac14b9..8e8f449fc59 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,6 +81,22 @@ export type GatewayConnectionDetails = { message: string; }; +function shouldAttachDeviceIdentityForGatewayCall(params: { + url: string; + token?: string; + password?: string; +}): boolean { + if (!(params.token || params.password)) { + return true; + } + try { + const parsed = new URL(params.url); + return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); + } catch { + return true; + } +} + export type ExplicitGatewayAuth = { token?: string; password?: string; @@ -818,7 +834,9 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: loadOrCreateDeviceIdentity(), + deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({ url, token, password }) + ? loadOrCreateDeviceIdentity() + : undefined, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => { diff --git a/src/gateway/control-ui-routing.test.ts b/src/gateway/control-ui-routing.test.ts index f3f172cc7d4..929c645cd01 100644 --- a/src/gateway/control-ui-routing.test.ts +++ b/src/gateway/control-ui-routing.test.ts @@ -2,65 +2,114 @@ import { describe, expect, it } from "vitest"; import { classifyControlUiRequest } from "./control-ui-routing.js"; describe("classifyControlUiRequest", () => { - it("falls through non-read root requests for plugin webhooks", () => { - const classified = classifyControlUiRequest({ - basePath: "", - pathname: "/bluebubbles-webhook", - search: "", - method: "POST", + describe("root-mounted control ui", () => { + it.each([ + { + name: "serves the root entrypoint", + pathname: "/", + method: "GET", + expected: { kind: "serve" as const }, + }, + { + name: "serves other read-only SPA routes", + pathname: "/chat", + method: "HEAD", + expected: { kind: "serve" as const }, + }, + { + name: "keeps health probes outside the SPA catch-all", + pathname: "/healthz", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps readiness probes outside the SPA catch-all", + pathname: "/ready", + method: "HEAD", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps plugin routes outside the SPA catch-all", + pathname: "/plugins/webhook", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "keeps API routes outside the SPA catch-all", + pathname: "/api/sessions", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "returns not-found for legacy ui routes", + pathname: "/ui/settings", + method: "GET", + expected: { kind: "not-found" as const }, + }, + { + name: "falls through non-read requests", + pathname: "/bluebubbles-webhook", + method: "POST", + expected: { kind: "not-control-ui" as const }, + }, + ])("$name", ({ pathname, method, expected }) => { + expect( + classifyControlUiRequest({ + basePath: "", + pathname, + search: "", + method, + }), + ).toEqual(expected); }); - expect(classified).toEqual({ kind: "not-control-ui" }); }); - it("returns not-found for legacy /ui routes when root-mounted", () => { - const classified = classifyControlUiRequest({ - basePath: "", - pathname: "/ui/settings", - search: "", - method: "GET", - }); - expect(classified).toEqual({ kind: "not-found" }); - }); - - it("falls through basePath non-read methods for plugin webhooks", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw", - search: "", - method: "POST", - }); - expect(classified).toEqual({ kind: "not-control-ui" }); - }); - - it("falls through PUT/DELETE/PATCH/OPTIONS under basePath for plugin handlers", () => { - for (const method of ["PUT", "DELETE", "PATCH", "OPTIONS"]) { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", + describe("basePath-mounted control ui", () => { + it.each([ + { + name: "redirects the basePath entrypoint", + pathname: "/openclaw", + search: "?foo=1", + method: "GET", + expected: { kind: "redirect" as const, location: "/openclaw/?foo=1" }, + }, + { + name: "serves nested read-only routes", + pathname: "/openclaw/chat", + search: "", + method: "HEAD", + expected: { kind: "serve" as const }, + }, + { + name: "falls through unmatched paths", + pathname: "/elsewhere/chat", + search: "", + method: "GET", + expected: { kind: "not-control-ui" as const }, + }, + { + name: "falls through write requests to the basePath entrypoint", + pathname: "/openclaw", + search: "", + method: "POST", + expected: { kind: "not-control-ui" as const }, + }, + ...["PUT", "DELETE", "PATCH", "OPTIONS"].map((method) => ({ + name: `falls through ${method} subroute requests`, pathname: "/openclaw/webhook", search: "", method, - }); - expect(classified, `${method} should fall through`).toEqual({ kind: "not-control-ui" }); - } - }); - - it("returns redirect for basePath entrypoint GET", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw", - search: "?foo=1", - method: "GET", + expected: { kind: "not-control-ui" as const }, + })), + ])("$name", ({ pathname, search, method, expected }) => { + expect( + classifyControlUiRequest({ + basePath: "/openclaw", + pathname, + search, + method, + }), + ).toEqual(expected); }); - expect(classified).toEqual({ kind: "redirect", location: "/openclaw/?foo=1" }); - }); - - it("classifies basePath subroutes as control ui", () => { - const classified = classifyControlUiRequest({ - basePath: "/openclaw", - pathname: "/openclaw/chat", - search: "", - method: "HEAD", - }); - expect(classified).toEqual({ kind: "serve" }); }); }); diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index a63bb1590e2..54cf972e79c 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -40,6 +40,25 @@ describe("handleControlUiHttpRequest", () => { expect(params.end).toHaveBeenCalledWith("Not Found"); } + function expectUnhandledRoutes(params: { + urls: string[]; + method: "GET" | "POST"; + rootPath: string; + basePath?: string; + expectationLabel: string; + }) { + for (const url of params.urls) { + const { handled, end } = runControlUiRequest({ + url, + method: params.method, + rootPath: params.rootPath, + ...(params.basePath ? { basePath: params.basePath } : {}), + }); + expect(handled, `${params.expectationLabel}: ${url}`).toBe(false); + expect(end, `${params.expectationLabel}: ${url}`).not.toHaveBeenCalled(); + } + } + function runControlUiRequest(params: { url: string; method: "GET" | "HEAD" | "POST"; @@ -147,53 +166,80 @@ describe("handleControlUiHttpRequest", () => { }); }); - it("serves bootstrap config JSON", async () => { + it.each([ + { + name: "at root", + url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, + expectedBasePath: "", + assistantName: ".png", + expectedAvatarUrl: "/avatar/main", + }, + { + name: "under basePath", + url: `/openclaw${CONTROL_UI_BOOTSTRAP_CONFIG_PATH}`, + basePath: "/openclaw", + expectedBasePath: "/openclaw", + assistantName: "Ops", + assistantAvatar: "ops.png", + expectedAvatarUrl: "/openclaw/avatar/main", + }, + ])("serves bootstrap config JSON $name", async (testCase) => { await withControlUiRoot({ fn: async (tmp) => { const { res, end } = makeMockHttpResponse(); const handled = handleControlUiHttpRequest( - { url: CONTROL_UI_BOOTSTRAP_CONFIG_PATH, method: "GET" } as IncomingMessage, + { url: testCase.url, method: "GET" } as IncomingMessage, res, { + ...(testCase.basePath ? { basePath: testCase.basePath } : {}), root: { kind: "resolved", path: tmp }, config: { agents: { defaults: { workspace: tmp } }, - ui: { assistant: { name: ".png" } }, + ui: { + assistant: { + name: testCase.assistantName, + avatar: testCase.assistantAvatar, + }, + }, }, }, ); expect(handled).toBe(true); const parsed = parseBootstrapPayload(end); - expect(parsed.basePath).toBe(""); - expect(parsed.assistantName).toBe("