diff --git a/.dockerignore b/.dockerignore index 3a8e436d515..f24c490e9ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,11 @@ .git .worktrees + +# Sensitive files – docker-setup.sh writes .env with OPENCLAW_GATEWAY_TOKEN +# into the project root; keep it out of the build context. +.env +.env.* + .bun-cache .bun .tmp diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..34992fc7a0e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,9 @@ +# Protect the ownership rules themselves. +/.github/CODEOWNERS @steipete + +# Release workflow and its supporting release-path checks. +/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers +/docs/reference/RELEASING.md @openclaw/openclaw-release-managers +/scripts/openclaw-npm-publish.sh @openclaw/openclaw-release-managers +/scripts/openclaw-npm-release-check.ts @openclaw/openclaw-release-managers +/scripts/release-check.ts @openclaw/openclaw-release-managers diff --git a/.github/actions/setup-node-env/action.yml b/.github/actions/setup-node-env/action.yml index c46387517e4..41ca9eb98b0 100644 --- a/.github/actions/setup-node-env/action.yml +++ b/.github/actions/setup-node-env/action.yml @@ -1,12 +1,16 @@ name: Setup Node environment description: > - Initialize submodules with retry, install Node 22, pnpm, optionally Bun, + Initialize submodules with retry, install Node 24 by default, pnpm, optionally Bun, and optionally run pnpm install. Requires actions/checkout to run first. inputs: node-version: description: Node.js version to install. required: false - default: "22.x" + default: "24.x" + cache-key-suffix: + description: Suffix appended to the pnpm store cache key. + required: false + default: "node24" pnpm-version: description: pnpm version for corepack. required: false @@ -16,7 +20,7 @@ inputs: required: false default: "true" use-sticky-disk: - description: Use Blacksmith sticky disks for pnpm store caching. + description: Request Blacksmith sticky-disk pnpm caching on trusted runs; pull_request runs fall back to actions/cache. required: false default: "false" install-deps: @@ -45,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 @@ -54,12 +58,12 @@ runs: uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: ${{ inputs.pnpm-version }} - cache-key-suffix: "node22" + cache-key-suffix: ${{ inputs.cache-key-suffix }} use-sticky-disk: ${{ inputs.use-sticky-disk }} - 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 e1e5a34abda..2f7c992a978 100644 --- a/.github/actions/setup-pnpm-store-cache/action.yml +++ b/.github/actions/setup-pnpm-store-cache/action.yml @@ -8,9 +8,9 @@ inputs: cache-key-suffix: description: Suffix appended to the cache key. required: false - default: "node22" + default: "node24" use-sticky-disk: - description: Use Blacksmith sticky disks instead of actions/cache for pnpm store. + description: Use Blacksmith sticky disks instead of actions/cache for pnpm store on trusted runs; pull_request runs fall back to actions/cache. required: false default: "false" use-restore-keys: @@ -18,7 +18,7 @@ inputs: required: false default: "true" use-actions-cache: - description: Whether to restore/save pnpm store with actions/cache. + description: Whether to restore/save pnpm store with actions/cache, including pull_request fallback when sticky disks are disabled. required: false default: "true" runs: @@ -51,22 +51,24 @@ runs: run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" - name: Mount pnpm store sticky disk - if: inputs.use-sticky-disk == 'true' + # Keep persistent sticky-disk state off untrusted PR runs. + if: inputs.use-sticky-disk == 'true' && github.event_name != 'pull_request' uses: useblacksmith/stickydisk@v1 with: - key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ inputs.cache-key-suffix }} + key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ github.ref_name }}-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }} path: ${{ steps.pnpm-store.outputs.path }} - name: Restore pnpm store cache (exact key only) - if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys != 'true' - uses: actions/cache@v4 + # 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@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' && inputs.use-restore-keys == 'true' - uses: actions/cache@v4 + if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true' + 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/labeler.yml b/.github/labeler.yml index ffe55984ac6..91c202b7ed6 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,7 +6,6 @@ "channel: discord": - changed-files: - any-glob-to-any-file: - - "src/discord/**" - "extensions/discord/**" - "docs/channels/discord.md" "channel: irc": @@ -28,7 +27,6 @@ "channel: imessage": - changed-files: - any-glob-to-any-file: - - "src/imessage/**" - "extensions/imessage/**" - "docs/channels/imessage.md" "channel: line": @@ -64,19 +62,16 @@ "channel: signal": - changed-files: - any-glob-to-any-file: - - "src/signal/**" - "extensions/signal/**" - "docs/channels/signal.md" "channel: slack": - changed-files: - any-glob-to-any-file: - - "src/slack/**" - "extensions/slack/**" - "docs/channels/slack.md" "channel: telegram": - changed-files: - any-glob-to-any-file: - - "src/telegram/**" - "extensions/telegram/**" - "docs/channels/telegram.md" "channel: tlon": @@ -96,7 +91,6 @@ "channel: whatsapp-web": - changed-files: - any-glob-to-any-file: - - "src/web/**" - "extensions/whatsapp/**" - "docs/channels/whatsapp.md" "channel: zalo": 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 2562d84d223..00670107d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,10 @@ on: concurrency: group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: true + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). @@ -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 @@ -35,9 +38,8 @@ jobs: id: check uses: ./.github/actions/detect-docs-changes - # Detect which heavy areas are touched so PRs can skip unrelated expensive jobs. - # Push to main keeps broad coverage, but this job still needs to run so - # downstream jobs that list it in `needs` are not skipped. + # Detect which heavy areas are touched so CI can skip unrelated expensive jobs. + # Fail-safe: if detection fails, downstream jobs run. changed-scope: needs: [docs-scope] if: needs.docs-scope.outputs.docs_only != 'true' @@ -50,7 +52,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 @@ -79,11 +81,11 @@ jobs: # Build dist once for Node-relevant changes and share it with downstream jobs. build-artifacts: needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -98,13 +100,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 +119,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -125,10 +127,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/ @@ -138,7 +140,7 @@ jobs: checks: needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 strategy: fail-fast: false @@ -146,6 +148,13 @@ jobs: include: - runtime: node task: test + shard_index: 1 + shard_count: 2 + command: pnpm canvas:a2ui:bundle && pnpm test + - runtime: node + task: test + shard_index: 2 + shard_count: 2 command: pnpm canvas:a2ui:bundle && pnpm test - runtime: node task: extensions @@ -157,44 +166,51 @@ jobs: task: test command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts steps: - - name: Skip bun lane on push - if: github.event_name == 'push' && matrix.runtime == 'bun' - run: echo "Skipping bun test lane on push events." + - name: Skip bun lane on pull requests + if: github.event_name == 'pull_request' && matrix.runtime == 'bun' + run: echo "Skipping Bun compatibility lane on pull requests." - name: Checkout - if: github.event_name != 'push' || matrix.runtime != 'bun' - uses: actions/checkout@v4 + if: github.event_name != 'pull_request' || matrix.runtime != 'bun' + uses: actions/checkout@v6 with: submodules: false - name: Setup Node environment - if: matrix.runtime != 'bun' || github.event_name != 'push' + if: matrix.runtime != 'bun' || github.event_name != 'pull_request' 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' + if: (github.event_name != 'pull_request' || matrix.runtime != 'bun') && matrix.task == 'test' && matrix.runtime == 'node' + env: + SHARD_COUNT: ${{ matrix.shard_count || '' }} + SHARD_INDEX: ${{ matrix.shard_index || '' }} run: | # `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes. # Default heap limits have been too low on Linux CI (V8 OOM near 4GB). echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" + if [ -n "$SHARD_COUNT" ] && [ -n "$SHARD_INDEX" ]; then + echo "OPENCLAW_TEST_SHARDS=$SHARD_COUNT" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV" + fi - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) - if: matrix.runtime != 'bun' || github.event_name != 'push' + if: matrix.runtime != 'bun' || github.event_name != 'pull_request' run: ${{ matrix.command }} # Types, lint, and format check. check: name: "check" needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -202,7 +218,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 +236,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -228,23 +244,57 @@ 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 - skills-python: + compat-node22: + name: "compat-node22" needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true') + if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + submodules: false + + - name: Setup Node 22 compatibility environment + uses: ./.github/actions/setup-node-env + with: + node-version: "22.x" + cache-key-suffix: "node22" + install-bun: "false" + use-sticky-disk: "false" + + - name: Configure Node 22 test resources + run: | + # Keep the compatibility lane aligned with the default Node test lane. + echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV" + echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV" + + - name: Build under Node 22 + run: pnpm build + + - name: Run tests under Node 22 + run: pnpm test + + - name: Verify npm pack under Node 22 + run: pnpm release:check + + skills-python: + needs: [docs-scope, changed-scope] + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_skills_python == 'true' + runs-on: blacksmith-16vcpu-ubuntu-2404 + steps: + - name: Checkout + 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" @@ -263,7 +313,7 @@ jobs: runs-on: blacksmith-16vcpu-ubuntu-2404 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -282,7 +332,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" @@ -292,7 +342,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') }} @@ -328,7 +378,7 @@ jobs: checks-windows: needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_windows == 'true') + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true' runs-on: blacksmith-32vcpu-windows-2025 timeout-minutes: 45 env: @@ -375,7 +425,7 @@ jobs: command: pnpm test steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -399,16 +449,16 @@ jobs: } - name: Setup Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@v6 with: - node-version: 22.x + node-version: 24.x check-latest: false - name: Setup pnpm + cache store uses: ./.github/actions/setup-pnpm-store-cache with: pnpm-version: "10.23.0" - cache-key-suffix: "node22" + cache-key-suffix: "node24" # Sticky disk mount currently retries/fails on every shard and adds ~50s # before install while still yielding zero pnpm store reuse. # Try exact-key actions/cache restores instead to recover store reuse @@ -461,7 +511,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -497,7 +547,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') }} @@ -533,7 +583,7 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: submodules: false @@ -690,7 +740,7 @@ jobs: android: needs: [docs-scope, changed-scope] - if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true') + if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_android == 'true' runs-on: blacksmith-16vcpu-ubuntu-2404 strategy: fail-fast: false @@ -702,31 +752,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 2cc29748c91..791a378b439 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -12,19 +12,63 @@ on: - "**/*.mdx" - ".agents/**" - "skills/**" + workflow_dispatch: + inputs: + tag: + description: Existing release tag to backfill (for example v2026.3.13) + required: true + type: string concurrency: - group: docker-release-${{ github.workflow }}-${{ github.ref }} + group: docker-release-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} cancel-in-progress: false env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: + validate_manual_backfill: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Validate tag input format + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then + echo "Invalid release tag: ${RELEASE_TAG}" + exit 1 + fi + + - name: Checkout selected tag + uses: actions/checkout@v6 + with: + ref: refs/tags/${{ inputs.tag }} + fetch-depth: 0 + + approve_manual_backfill: + if: github.event_name == 'workflow_dispatch' + needs: validate_manual_backfill + # WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT. + runs-on: ubuntu-24.04 + environment: docker-release + steps: + - name: Approve Docker backfill + run: echo "Approved Docker backfill for ${{ inputs.tag }}" + + # KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS. + # DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS. # Build amd64 images (default + slim share the build stage cache) build-amd64: - runs-on: blacksmith-16vcpu-ubuntu-2404 + needs: [approve_manual_backfill] + if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }} + # WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS. + runs-on: ubuntu-24.04 permissions: packages: write contents: read @@ -33,13 +77,16 @@ jobs: slim-digest: ${{ steps.build-slim.outputs.digest }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} + fetch-depth: 0 - name: Set up Docker Builder - uses: useblacksmith/setup-docker-builder@v1 + 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 }} @@ -50,21 +97,22 @@ jobs: shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail tags=() slim_tags=() - if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then tags+=("${IMAGE}:main-amd64") slim_tags+=("${IMAGE}:main-slim-amd64") fi - if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - version="${GITHUB_REF#refs/tags/v}" + if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then + version="${SOURCE_REF#refs/tags/v}" tags+=("${IMAGE}:${version}-amd64") slim_tags+=("${IMAGE}:${version}-slim-amd64") fi if [[ ${#tags[@]} -eq 0 ]]; then - echo "::error::No amd64 tags resolved for ref ${GITHUB_REF}" + echo "::error::No amd64 tags resolved for ref ${SOURCE_REF}" exit 1 fi { @@ -81,19 +129,22 @@ jobs: - name: Resolve OCI labels (amd64) id: labels shell: bash + env: + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }} run: | set -euo pipefail - version="${GITHUB_SHA}" - if [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then + source_sha="$(git rev-parse HEAD)" + version="${source_sha}" + if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then version="main" fi - if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then - version="${GITHUB_REF#refs/tags/v}" + if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then + version="${SOURCE_REF#refs/tags/v}" fi created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" { echo "value</dev/null 2>&1; then + echo "openclaw@${PACKAGE_VERSION} is already published on npm." + exit 1 + fi + + echo "Previewing openclaw@${PACKAGE_VERSION}" + + - name: Check + run: | + set -euxo pipefail + pnpm check + + - name: Build + run: | + set -euxo pipefail + pnpm build + + - name: Verify release contents + run: | + set -euxo pipefail + pnpm release:check + + - name: Preview publish command + run: bash scripts/openclaw-npm-publish.sh --dry-run + publish_openclaw_npm: + if: github.event_name == 'workflow_dispatch' # npm trusted publishing + provenance requires a GitHub-hosted runner. runs-on: ubuntu-latest + environment: npm-release permissions: contents: read id-token: write steps: + - name: Validate tag input format + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then + echo "Invalid release tag format: ${RELEASE_TAG}" + exit 1 + fi + - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: + ref: refs/tags/${{ inputs.tag }} fetch-depth: 0 - name: Setup Node environment @@ -36,11 +135,12 @@ jobs: - name: Validate release tag and package metadata env: - RELEASE_SHA: ${{ github.sha }} - RELEASE_TAG: ${{ github.ref_name }} + RELEASE_TAG: ${{ inputs.tag }} RELEASE_MAIN_REF: origin/main run: | set -euo pipefail + RELEASE_SHA=$(git rev-parse HEAD) + export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF # Fetch the full main ref so merge-base ancestry checks keep working # for older tagged commits that are still contained in main. git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main @@ -68,12 +168,4 @@ jobs: run: pnpm release:check - name: Publish - run: | - set -euo pipefail - PACKAGE_VERSION=$(node -p "require('./package.json').version") - - if [[ "$PACKAGE_VERSION" == *-beta.* ]]; then - npm publish --access public --tag beta --provenance - else - npm publish --access public --provenance - fi + run: bash scripts/openclaw-npm-publish.sh --publish diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 13688bd0f25..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: useblacksmith/setup-docker-builder@v1 + 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 4defa8acb33..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 @@ -123,3 +124,12 @@ dist/protocol.schema.json # Synthing **/.stfolder/ .dev-state +docs/superpowers/plans/2026-03-10-collapsed-side-nav.md +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__ +ui/src/ui/views/__screenshots__ +ui/.vitest-attachments +docs/superpowers diff --git a/.jscpd.json b/.jscpd.json new file mode 100644 index 00000000000..777b025b0c8 --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,16 @@ +{ + "gitignore": true, + "noSymlinks": true, + "ignore": [ + "**/node_modules/**", + "**/dist/**", + "dist/**", + "**/.git/**", + "**/coverage/**", + "**/build/**", + "**/.build/**", + "**/.artifacts/**", + "docs/zh-CN/**", + "**/CHANGELOG.md" + ] +} diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000000..7cd53fdbc08 --- /dev/null +++ b/.npmignore @@ -0,0 +1 @@ +**/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 69b0df68faa..0b1e17c8b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,6 +118,7 @@ - Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. - Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys. +- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse"). ## Release Channels (Naming) @@ -131,6 +132,7 @@ - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). - Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`. - Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic. +- For targeted/local debugging, keep using the wrapper: `pnpm test -- [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing. - Do not set test workers above 16; tried already. - If local Vitest runs cause memory pressure (common on non-Mac-Studio hosts), use `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test` for land/gate runs. - Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`. @@ -200,6 +202,44 @@ ## Agent-Specific Notes - Vocabulary: "makeup" = "mac app". +- Parallels macOS retests: use the snapshot most closely named like `macOS 26.3.1 fresh` when the user asks for a clean/fresh macOS rerun; avoid older Tahoe snapshots unless explicitly requested. +- Parallels beta smoke: use `--target-package-spec openclaw@` for the beta artifact, and pin the stable side with both `--install-version ` and `--latest-version ` for upgrade runs. npm dist-tags can move mid-run. +- Parallels beta smoke, Windows nuance: old stable `2026.3.12` still prints the Unicode Windows onboarding banner, so mojibake during the stable precheck log is expected there. Judge the beta package by the post-upgrade lane. +- Parallels macOS smoke playbook: + - `prlctl exec` is fine for deterministic repo commands, but it can misrepresent interactive shell behavior (`PATH`, `HOME`, `curl | bash`, shebang resolution). For installer parity or shell-sensitive repros, prefer the guest Terminal or `prlctl enter`. + - Fresh Tahoe snapshot current reality: `brew` exists, `node` may not be on `PATH` in noninteractive guest exec. Use absolute `/opt/homebrew/bin/node` for repo/CLI runs when needed. + - Preferred automation entrypoint: `pnpm test:parallels:macos`. It restores the snapshot most closely matching `macOS 26.3.1 fresh`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero. + - Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-smoke.*`. + - All-OS parallel runs should share the host `dist` build via `/tmp/openclaw-parallels-build.lock` instead of rebuilding three times. + - Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails. + - Fresh host-served tgz install: restore fresh snapshot, install tgz as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`. + - For `openclaw onboard --non-interactive --secret-input-mode ref --install-daemon`, expect env-backed auth-profile refs (for example `OPENAI_API_KEY`) to be copied into the service env at install time; this path was fixed and should stay green. + - Don’t run local + gateway agent turns in parallel on the same fresh workspace/session; they can collide on the session lock. Run sequentially. + - Root-installed tarball smoke on Tahoe can still log plugin blocks for world-writable `extensions/*` under `/opt/homebrew/lib/node_modules/openclaw`; treat that as separate from onboarding/gateway health unless the task is plugin loading. +- Parallels Windows smoke playbook: + - Preferred automation entrypoint: `pnpm test:parallels:windows`. It restores the snapshot most closely matching `pre-openclaw-native-e2e-2026-03-12`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc`, not plain `--deep`, so probe failures go non-zero. + - Latest-release pre-upgrade diagnostics still need compatibility fallback: stable `2026.3.12` does not know `--require-rpc`, so precheck status dumps should fall back to plain `gateway status --deep` until the guest is upgraded. + - Always use `prlctl exec --current-user` for Windows guest runs; plain `prlctl exec` lands in `NT AUTHORITY\SYSTEM` and does not match the real desktop-user install path. + - Prefer explicit `npm.cmd` / `openclaw.cmd`. Bare `npm` / `openclaw` in PowerShell can hit the `.ps1` shim and fail under restrictive execution policy. + - Use PowerShell only as the transport (`powershell.exe -NoProfile -ExecutionPolicy Bypass`) and call the `.cmd` shims explicitly from inside it. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-windows.*`. + - Current expected outcome on latest stable pre-upgrade: `precheck=latest-ref-fail` is normal on `2026.3.12`; treat it as a baseline signal, not a regression, unless the post-upgrade `main` lane also fails. + - Keep Windows onboarding/status text ASCII-clean in logs. Fancy punctuation in banners shows up as mojibake through the current guest PowerShell capture path. +- Parallels Linux smoke playbook: + - Preferred automation entrypoint: `pnpm test:parallels:linux`. It restores the snapshot most closely matching `fresh` on `Ubuntu 24.04.3 ARM64`, serves the current `main` tarball from the host, then runs fresh-install and latest-release-to-main smoke lanes. + - Use plain `prlctl exec` on this snapshot. `--current-user` is not the right transport there. + - Fresh snapshot reality: `curl` is missing and `apt-get update` can fail on clock skew. Bootstrap with `apt-get -o Acquire::Check-Date=false update` and install `curl ca-certificates` before testing installer paths. + - Fresh `main` tgz smoke on Linux still needs the latest-release installer first, because this snapshot has no Node/npm before bootstrap. The harness does stable bootstrap first, then overlays current `main`. + - This snapshot does not have a usable `systemd --user` session. Treat managed daemon install as unsupported here; use `--skip-health`, then verify with direct `openclaw gateway run --bind loopback --port 18789 --force`. + - Env-backed auth refs are still fine, but any direct shell launch (`openclaw gateway run`, `openclaw agent --local`, Linux `gateway status --deep` against that direct run) must inherit the referenced env vars in the same shell. + - `prlctl exec` reaps detached Linux child processes on this snapshot, so a background `openclaw gateway run` launched from automation is not a trustworthy smoke path. The harness verifies installer + `agent --local`; do direct gateway checks only from an interactive guest shell when needed. + - When you do run Linux gateway checks manually from an interactive guest shell, use `openclaw gateway status --deep --require-rpc` so an RPC miss is a hard failure. + - Prefer direct argv guest commands for fetch/install steps (`curl`, `npm install -g`, `openclaw ...`) over nested `bash -lc` quoting; Linux guest quoting through Parallels was the flaky part. + - Harness output: pass `--json` for machine-readable summary; per-phase logs land under `/tmp/openclaw-parallels-linux.*`. + - Current expected outcome on Linux smoke: fresh + upgrade should pass installer and `agent --local`; gateway remains `skipped-no-detached-linux-gateway` on this snapshot and should not be treated as a regression by itself. - Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`. - When adding a new `AGENTS.md` anywhere in the repo, also add a `CLAUDE.md` symlink pointing to it (example: `ln -s AGENTS.md CLAUDE.md`). - Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8fff777cb..5885a548e0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,30 +4,179 @@ Docs: https://docs.openclaw.ai ## Unreleased -### Security - -- 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. -- 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. -- 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/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. -- 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/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/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. - ### Changes +- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. +- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. + ### Fixes -- 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. -- 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. -- 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. -- 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. -- 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. -- 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. +- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) +- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. + +## 2026.3.13 + +### 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. +- 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. +- Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for `chrome://inspect/#remote-debugging` enablement and direct backlinks to Chrome’s own setup guides. +- Browser/agents: add built-in `profile="user"` for the logged-in host browser and `profile="chrome-relay"` for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra `browserSession` selector. +- Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc. +- 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. +- Dependencies/pi: bump `@mariozechner/pi-agent-core`, `@mariozechner/pi-ai`, `@mariozechner/pi-coding-agent`, and `@mariozechner/pi-tui` to `0.58.0`. +- Cron/sessions: add `sessionTarget: "current"` and `session:` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF. +- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent. + +### Breaking + +- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei. + +### Fixes + +- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev. +- Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging `GatewayClient.request()` promises indefinitely. +- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. +- 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. +- 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. +- Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0. +- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata. +- 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. +- 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. +- 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. +- Gateway/status: add `openclaw gateway status --require-rpc` and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green. +- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens. +- 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. +- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images. +- 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 stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart. +- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`. +- 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. +- 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. +- Slack/probe: keep `auth.test()` bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss. +- Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes. +- Dashboard/chat UI: restore the `chat-new-messages` class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han. +- Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom. +- macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance. +- Discord/allowlists: honor raw `guild_id` when hydrated guild objects are missing so allowlisted channels and threads like `#maintainers` no longer get false-dropped before channel allowlist checks. +- macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo. +- Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu. +- Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to `google-vertex` model refs and provider configs so `google-vertex/gemini-3.1-flash-lite` resolves as `gemini-3.1-flash-lite-preview`. (#42435) thanks @scoootscooob. +- iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua. +- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08. +- Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey. +- Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed `EXTERNAL_UNTRUSTED_CONTENT` markers fall back to the existing hardening path instead of bypassing marker normalization. +- 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. +- Security/exec approvals: recognize PowerShell `-File` and `-f` wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing `-Command` variants. +- Security/exec approvals: unwrap `env` dispatch wrappers inside shell-segment allowlist resolution on macOS so `env FOO=bar /path/to/bin` resolves against the effective executable instead of the wrapper token. +- Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued `$(` substitutions fail closed instead of slipping past command-substitution checks. +- Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins. +- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn. +- Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc. +- 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. +- 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. +- 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. +- 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. +- 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. +- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. +- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. +- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix + +## 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. +- Slack/interactive replies: add opt-in Slack button and select reply directives behind `channels.slack.capabilities.interactiveReplies`, disabled by default unless explicitly enabled. (#44607) 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. +- Config/Anthropic startup: inline Anthropic alias normalization during config load so gateway startup no longer crashes on dated Anthropic model refs like `anthropic/claude-sonnet-4-20250514`. (#45520) Thanks @BunsDev. +- 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. +- Browser/existing-session: stop reporting fake CDP ports/URLs for live attached Chrome sessions, render `transport: chrome-mcp` in CLI/status output instead of `port: 0`, and keep timeout diagnostics transport-aware when no direct CDP URL exists. +- 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. +- Feishu/event dedupe: keep early duplicate suppression aligned with the shared Feishu message-id contract and release the pre-queue dedupe marker after failed dispatch so retried events can recover instead of being dropped until the short TTL expires. (#43762) Thanks @yunweibang. +- 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. +- 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. +- Docs/Brave pricing: escape literal dollar signs in Brave Search cost text so the docs render the free credit and per-request pricing correctly. (#44989) Thanks @keelanfh. +- Feishu/file uploads: preserve literal UTF-8 filenames in `im.file.create` so Chinese and other non-ASCII filenames no longer appear percent-encoded in chat. (#34262) Thanks @fabiaodemianyang and @KangShuaiFu. ## 2026.3.11 @@ -53,6 +202,8 @@ Docs: https://docs.openclaw.ai - Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle. - Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc. - LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF. +- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix. +- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman. ### Breaking @@ -60,6 +211,11 @@ Docs: https://docs.openclaw.ai ### Fixes +- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies. +- Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs. +- Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates. +- Windows/onboarding: explain when non-interactive local onboarding is waiting for an already-running gateway, and surface native Scheduled Task admin requirements more clearly instead of failing with an opaque gateway timeout. +- Windows/gateway install: fall back from denied Scheduled Task creation to a per-user Startup-folder login item, so native `openclaw gateway install` and `--install-daemon` keep working without an elevated PowerShell shell. - Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern. - iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky. - Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark. @@ -116,6 +272,7 @@ Docs: https://docs.openclaw.ai - Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting. - Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth. - Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set. +- Sandbox/sessions_spawn: restore real workspace handoff for read-only sandboxed sessions so spawned subagents mount the configured workspace at `/agent` instead of inheriting the sandbox copy. Related #40582. - Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94. - Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo. - Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey. @@ -152,6 +309,15 @@ Docs: https://docs.openclaw.ai - Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc. - Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches. - Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang. +- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo. +- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006. +- Status/context windows: normalize provider-qualified override cache keys so `/status` resolves the active provider's configured context window even when `models.providers` keys use mixed case or surrounding whitespace. (#36389) Thanks @haoruilee. +- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692) +- Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii. +- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke. +- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode. +- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows. +- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint. ## 2026.3.8 @@ -227,6 +393,10 @@ Docs: https://docs.openclaw.ai - CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc. - Memory/Gemini: normalize returned Gemini embeddings across direct query, direct batch, and async batch paths so memory search uses consistent vector handling for Gemini too. (#43409) Thanks @gumadeiras. - Agents/failover: recognize additional serialized network errno strings plus `EHOSTDOWN` and `EPIPE` structured codes so transient transport failures trigger timeout failover more reliably. (#42830) Thanks @jnMetaCode. +- 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 @@ -3151,7 +3321,7 @@ Docs: https://docs.openclaw.ai - Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies. - Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566) - Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467) -- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998) +- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (#45459) Thanks @LyttonFeng and @vincentkoc. - Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu. - Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. - Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7808db9cf8..8b9e62a3d74 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 @@ -61,7 +61,7 @@ Welcome to the lobster tank! 🦞 - **Josh Lehman** - Compaction, Tlon/Urbit subsystem - GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_) -- **Radek Sienkiewicz** - Control UI + WebChat correctness +- **Radek Sienkiewicz** - Docs, Control UI - GitHub [@velvet-shark](https://github.com/velvet-shark) · X: [@velvet_shark](https://twitter.com/velvet_shark) - **Muhammed Mukhthar** - Mattermost, CLI @@ -92,6 +92,7 @@ Welcome to the lobster tank! 🦞 - Describe what & why - Reply to or resolve bot review conversations you addressed before asking for review again - **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes) +- Use American English spelling and grammar in code, comments, docs, and UI strings ## Review Conversations Are Author-Owned diff --git a/Dockerfile b/Dockerfile index d6923365b4b..57a3440f385 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,14 +14,14 @@ # Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim . ARG OPENCLAW_EXTENSIONS="" ARG OPENCLAW_VARIANT=default -ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:22-bookworm@sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9" -ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9" -ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:22-bookworm-slim@sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9" -ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9" +ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b" +ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b" +ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb" +ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb" # Base images are pinned to SHA256 digests for reproducible builds. # Trade-off: digests must be updated manually when upstream tags move. -# To update, run: docker manifest inspect node:22-bookworm (or podman) +# To update, run: docker buildx imagetools inspect node:24-bookworm (or podman) # and replace the digest below with the current multi-arch manifest list entry. FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps @@ -39,8 +39,18 @@ RUN mkdir -p /out && \ # ── Stage 2: Build ────────────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build -# Install Bun (required for build scripts) -RUN curl -fsSL https://bun.sh/install | bash +# Install Bun (required for build scripts). Retry the whole bootstrap flow to +# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds. +RUN set -eux; \ + for attempt in 1 2 3 4 5; do \ + if curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL https://bun.sh/install | bash; then \ + break; \ + fi; \ + if [ "$attempt" -eq 5 ]; then \ + exit 1; \ + fi; \ + sleep $((attempt * 2)); \ + done ENV PATH="/root/.bun/bin:${PATH}" RUN corepack enable @@ -92,12 +102,12 @@ RUN CI=true pnpm prune --prod && \ # ── Runtime base images ───────────────────────────────────────── FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default ARG OPENCLAW_NODE_BOOKWORM_DIGEST -LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \ +LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm" \ org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}" FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST -LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm-slim" \ +LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-slim" \ org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}" # ── Stage 3: Runtime ──────────────────────────────────────────── @@ -122,6 +132,7 @@ WORKDIR /app RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ procps hostname curl git openssl @@ -141,7 +152,15 @@ COPY --from=runtime-assets --chown=node:node /app/docs ./docs ENV COREPACK_HOME=/usr/local/share/corepack RUN install -d -m 0755 "$COREPACK_HOME" && \ corepack enable && \ - corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \ + for attempt in 1 2 3 4 5; do \ + if corepack prepare "$(node -p "require('./package.json').packageManager")" --activate; then \ + break; \ + fi; \ + if [ "$attempt" -eq 5 ]; then \ + exit 1; \ + fi; \ + sleep $((attempt * 2)); \ + done && \ chmod -R a+rX "$COREPACK_HOME" # Install additional system packages needed by your skills or extensions. @@ -209,7 +228,7 @@ RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \ ENV NODE_ENV=production # Security hardening: Run as non-root user -# The node:22-bookworm image includes a 'node' user (uid 1000) +# The node:24-bookworm image includes a 'node' user (uid 1000) # This reduces the attack surface by preventing container escape via root privileges USER node diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index 8b50c7a6745..37cdab5fcd2 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/Dockerfile.sandbox-browser b/Dockerfile.sandbox-browser index f04e4a82a62..e8e8bb59f84 100644 --- a/Dockerfile.sandbox-browser +++ b/Dockerfile.sandbox-browser @@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends \ bash \ ca-certificates \ diff --git a/Dockerfile.sandbox-common b/Dockerfile.sandbox-common index 39eaa3692b4..fba29a5df3d 100644 --- a/Dockerfile.sandbox-common +++ b/Dockerfile.sandbox-common @@ -24,6 +24,7 @@ ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \ apt-get update \ + && apt-get upgrade -y --no-install-recommends \ && apt-get install -y --no-install-recommends ${PACKAGES} RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi diff --git a/SECURITY.md b/SECURITY.md index 204dadbf36d..bef814525a5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -37,6 +37,7 @@ For fastest triage, include all of the following: - Exact vulnerable path (`file`, function, and line range) on a current revision. - Tested version details (OpenClaw version and/or commit SHA). - Reproducible PoC against latest `main` or latest released version. +- If the claim targets a released version, evidence from the shipped tag and published artifact/package for that exact version (not only `main`). - Demonstrated impact tied to OpenClaw's documented trust boundaries. - For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services). - Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config. @@ -55,6 +56,7 @@ These are frequently reported but are typically closed with no code change: - Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass. - Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared gateway host/config. +- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries. - Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries. - ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. - Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive. @@ -65,6 +67,7 @@ These are frequently reported but are typically closed with no code change: - Discord inbound webhook signature findings for paths not used by this repo's Discord integration. - Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path. - Scanner-only claims against stale/nonexistent paths, or claims without a working repro. +- Reports that restate an already-fixed issue against later released versions without showing the vulnerable path still exists in the shipped tag or published artifact for that later version. ### Duplicate Report Handling @@ -90,6 +93,7 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary. - Authenticated Gateway callers are treated as trusted operators for that gateway instance. +- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split. - Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. - If one operator can view data from another operator on the same gateway, that is expected in this trust model. - OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. @@ -145,6 +149,7 @@ OpenClaw security guidance assumes: OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus." - If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions. +- Non-owner sender status only affects owner-only tools/commands. If a non-owner can still access a non-owner-only tool on that same agent (for example `canvas`), that is within the granted tool boundary unless the report demonstrates an auth, policy, allowlist, approval, or sandbox bypass. - Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries. - For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary. - A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only. diff --git a/Swabble/Sources/SwabbleKit/WakeWordGate.swift b/Swabble/Sources/SwabbleKit/WakeWordGate.swift index 27c952a8d1b..1a1479b630b 100644 --- a/Swabble/Sources/SwabbleKit/WakeWordGate.swift +++ b/Swabble/Sources/SwabbleKit/WakeWordGate.swift @@ -101,25 +101,19 @@ public enum WakeWordGate { } public static func commandText( - transcript: String, + transcript _: String, segments: [WakeWordSegment], triggerEndTime: TimeInterval) -> String { let threshold = triggerEndTime + 0.001 + var commandWords: [String] = [] + commandWords.reserveCapacity(segments.count) for segment in segments where segment.start >= threshold { - if normalizeToken(segment.text).isEmpty { continue } - if let range = segment.range { - let slice = transcript[range.lowerBound...] - return String(slice).trimmingCharacters(in: Self.whitespaceAndPunctuation) - } - break + let normalized = normalizeToken(segment.text) + if normalized.isEmpty { continue } + commandWords.append(segment.text) } - - let text = segments - .filter { $0.start >= threshold && !normalizeToken($0.text).isEmpty } - .map(\.text) - .joined(separator: " ") - return text.trimmingCharacters(in: Self.whitespaceAndPunctuation) + return commandWords.joined(separator: " ").trimmingCharacters(in: Self.whitespaceAndPunctuation) } public static func matchesTextOnly(text: String, triggers: [String]) -> Bool { diff --git a/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift b/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift index 5cc283c35ae..7e5b4abdd74 100644 --- a/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift +++ b/Swabble/Tests/SwabbleKitTests/WakeWordGateTests.swift @@ -46,6 +46,25 @@ import Testing let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config) #expect(match?.command == "do it") } + + @Test func commandTextHandlesForeignRangeIndices() { + let transcript = "hey clawd do thing" + let other = "do thing" + let foreignRange = other.range(of: "do") + let segments = [ + WakeWordSegment(text: "hey", start: 0.0, duration: 0.1, range: transcript.range(of: "hey")), + WakeWordSegment(text: "clawd", start: 0.2, duration: 0.1, range: transcript.range(of: "clawd")), + WakeWordSegment(text: "do", start: 0.9, duration: 0.1, range: foreignRange), + WakeWordSegment(text: "thing", start: 1.1, duration: 0.1, range: nil), + ] + + let command = WakeWordGate.commandText( + transcript: transcript, + segments: segments, + triggerEndTime: 0.3) + + #expect(command == "do thing") + } } private func makeSegments( diff --git a/appcast.xml b/appcast.xml index 4bceb205614..c1919972b22 100644 --- a/appcast.xml +++ b/appcast.xml @@ -2,6 +2,174 @@ OpenClaw + + 2026.3.13 + Sat, 14 Mar 2026 05:19:48 +0000 + https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml + 2026031390 + 2026.3.13 + 15.0 + OpenClaw 2026.3.13 +

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.
  • +
  • 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.
  • +
  • Browser/existing-session: add an official Chrome DevTools MCP attach mode for signed-in live Chrome sessions, with docs for chrome://inspect/#remote-debugging enablement and direct backlinks to Chrome’s own setup guides.
  • +
  • Browser/agents: add built-in profile="user" for the logged-in host browser and profile="chrome-relay" for the extension relay, so agent browser calls can prefer the real signed-in browser without the extra browserSession selector.
  • +
  • Browser/act automation: add batched actions, selector targeting, and delayed clicks for browser act requests with normalized batch dispatch. Thanks @vincentkoc.
  • +
  • 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.
  • +
  • Dependencies/pi: bump @mariozechner/pi-agent-core, @mariozechner/pi-ai, @mariozechner/pi-coding-agent, and @mariozechner/pi-tui to 0.58.0.
  • +
+

Fixes

+
    +
  • Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
  • +
  • Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging GatewayClient.request() promises indefinitely.
  • +
  • Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
  • +
  • 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.
  • +
  • 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.
  • +
  • Browser/existing-session: harden driver validation and session lifecycle so transport errors trigger reconnects while tool-level errors preserve the session, and extract shared ARIA role sets to deduplicate Playwright and Chrome MCP snapshot paths. (#45682) Thanks @odysseus0.
  • +
  • Browser/existing-session: accept text-only list_pages and new_page responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.
  • +
  • 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.
  • +
  • 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.
  • +
  • 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.
  • +
  • Gateway/status: add openclaw gateway status --require-rpc and clearer Linux non-interactive daemon-install failure reporting so automation can fail hard on probe misses instead of treating a printed RPC error as green.
  • +
  • macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered system.run requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
  • +
  • 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.
  • +
  • Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
  • +
  • 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 stop: resolve Startup-folder fallback listeners from the installed gateway.cmd port, so openclaw gateway stop now actually kills fallback-launched gateway processes before restart.
  • +
  • Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in gateway status --json instead of falling back to gateway port unknown.
  • +
  • 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.
  • +
  • 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.
  • +
  • Slack/probe: keep auth.test() bot and team metadata mapping stable while simplifying the probe result path. (#44775) Thanks @Cafexss.
  • +
  • Dashboard/chat UI: render oversized plain-text replies as normal paragraphs instead of capped gray code blocks, so long desktop chat responses stay readable without tab-switching refreshes.
  • +
  • Dashboard/chat UI: restore the chat-new-messages class on the New messages scroll pill so the button uses its existing compact styling instead of rendering as a full-screen SVG overlay. (#44856) Thanks @Astro-Han.
  • +
  • Gateway/Control UI: restore the operator-only device-auth bypass and classify browser connect failures so origin and device-identity problems no longer show up as auth errors in the Control UI and web chat. (#45512) thanks @sallyom.
  • +
  • macOS/voice wake: stop crashing wake-word command extraction when speech segment ranges come from a different transcript instance.
  • +
  • Discord/allowlists: honor raw guild_id when hydrated guild objects are missing so allowlisted channels and threads like #maintainers no longer get false-dropped before channel allowlist checks.
  • +
  • macOS/runtime locator: require Node >=22.16.0 during macOS runtime discovery so the app no longer accepts Node versions that the main runtime guard rejects later. Thanks @sumleo.
  • +
  • Agents/custom providers: preserve blank API keys for loopback OpenAI-compatible custom providers by clearing the synthetic Authorization header at runtime, while keeping explicit apiKey and oauth/token config from silently downgrading into fake bearer auth. (#45631) Thanks @xinhuagu.
  • +
  • Models/google-vertex Gemini flash-lite normalization: apply existing bare-ID preview normalization to google-vertex model refs and provider configs so google-vertex/gemini-3.1-flash-lite resolves as gemini-3.1-flash-lite-preview. (#42435) thanks @scoootscooob.
  • +
  • iMessage/remote attachments: reject unsafe remote attachment paths before spawning SCP, so sender-controlled filenames can no longer inject shell metacharacters into remote media staging. Thanks @lintsinghua.
  • +
  • Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
  • +
  • Security/device pairing: make bootstrap setup codes single-use so pending device pairing requests cannot be silently replayed and widened to admin before approval. Thanks @tdjackey.
  • +
  • Security/external content: strip zero-width and soft-hyphen marker-splitting characters during boundary sanitization so spoofed EXTERNAL_UNTRUSTED_CONTENT markers fall back to the existing hardening path instead of bypassing marker normalization.
  • +
  • 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.
  • +
  • Security/exec approvals: recognize PowerShell -File and -f wrapper forms during inline-command extraction so approval and command-analysis paths treat file-based PowerShell launches like the existing -Command variants.
  • +
  • Security/exec approvals: unwrap env dispatch wrappers inside shell-segment allowlist resolution on macOS so env FOO=bar /path/to/bin resolves against the effective executable instead of the wrapper token.
  • +
  • Security/exec approvals: treat backslash-newline as shell line continuation during macOS shell-chain parsing so line-continued $( substitutions fail closed instead of slipping past command-substitution checks.
  • +
  • Security/exec approvals: bind macOS skill auto-allow trust to both executable name and resolved path so same-basename binaries no longer inherit trust from unrelated skill bins.
  • +
  • Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
  • +
  • Cron/isolated sessions: route nested cron-triggered embedded runner work onto the nested lane so isolated cron jobs no longer deadlock when compaction or other queued inner work runs. Thanks @vincentkoc.
  • +
  • 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.
  • +
  • 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/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.
  • +
  • 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.
  • +
  • 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.
  • +
  • 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.
  • +
  • Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
  • +
+

View full changelog

+]]>
+ +
+ + 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 @@ -76,587 +244,5 @@ ]]> - - 2026.3.7 - Sun, 08 Mar 2026 04:42:35 +0000 - https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml - 2026030790 - 2026.3.7 - 15.0 - OpenClaw 2026.3.7 -

Changes

-
    -
  • Agents/context engine plugin interface: add ContextEngine plugin slot with full lifecycle hooks (bootstrap, ingest, assemble, compact, afterTurn, prepareSubagentSpawn, onSubagentEnded), slot-based registry with config-driven resolution, LegacyContextEngine wrapper preserving existing compaction behavior, scoped subagent runtime for plugin runtimes via AsyncLocalStorage, and sessions.get gateway method. Enables plugins like lossless-claw to provide alternative context management strategies without modifying core compaction logic. Zero behavior change when no context engine plugin is configured. (#22201) thanks @jalehman.
  • -
  • ACP/persistent channel bindings: add durable Discord channel and Telegram topic binding storage, routing resolution, and CLI/docs support so ACP thread targets survive restarts and can be managed consistently. (#34873) Thanks @dutifulbob.
  • -
  • Telegram/ACP topic bindings: accept Telegram Mac Unicode dash option prefixes in /acp spawn, support Telegram topic thread binding (--thread here|auto), route bound-topic follow-ups to ACP sessions, add actionable Telegram approval buttons with prefixed approval-id resolution, and pin successful bind confirmations in-topic. (#36683) Thanks @huntharo.
  • -
  • Telegram/topic agent routing: support per-topic agentId overrides in forum groups and DM topics so topics can route to dedicated agents with isolated sessions. (#33647; based on #31513) Thanks @kesor and @Sid-Qin.
  • -
  • Web UI/i18n: add Spanish (es) locale support in the Control UI, including locale detection, lazy loading, and language picker labels across supported locales. (#35038) Thanks @DaoPromociones.
  • -
  • Onboarding/web search: add provider selection step and full provider list in configure wizard, with SecretRef ref-mode support during onboarding. (#34009) Thanks @kesku and @thewilloftheshadow.
  • -
  • Tools/Web search: switch Perplexity provider to Search API with structured results plus new language/region/time filters. (#33822) Thanks @kesku.
  • -
  • Gateway: add SecretRef support for gateway.auth.token with auth-mode guardrails. (#35094) Thanks @joshavant.
  • -
  • Docker/Podman extension dependency baking: add OPENCLAW_EXTENSIONS so container builds can preinstall selected bundled extension npm dependencies into the image for faster and more reproducible startup in container deployments. (#32223) Thanks @sallyom.
  • -
  • Plugins/before_prompt_build system-context fields: add prependSystemContext and appendSystemContext so static plugin guidance can be placed in system prompt space for provider caching and lower repeated prompt token cost. (#35177) thanks @maweibin.
  • -
  • Plugins/hook policy: add plugins.entries..hooks.allowPromptInjection, validate unknown typed hook names at runtime, and preserve legacy before_agent_start model/provider overrides while stripping prompt-mutating fields when prompt injection is disabled. (#36567) thanks @gumadeiras.
  • -
  • Hooks/Compaction lifecycle: emit session:compact:before and session:compact:after internal events plus plugin compaction callbacks with session/count metadata, so automations can react to compaction runs consistently. (#16788) thanks @vincentkoc.
  • -
  • Agents/compaction post-context configurability: add agents.defaults.compaction.postCompactionSections so deployments can choose which AGENTS.md sections are re-injected after compaction, while preserving legacy fallback behavior when the documented default pair is configured in any order. (#34556) thanks @efe-arv.
  • -
  • TTS/OpenAI-compatible endpoints: add messages.tts.openai.baseUrl config support with config-over-env precedence, endpoint-aware directive validation, and OpenAI TTS request routing to the resolved base URL. (#34321) thanks @RealKai42.
  • -
  • Slack/DM typing feedback: add channels.slack.typingReaction so Socket Mode DMs can show reaction-based processing status even when Slack native assistant typing is unavailable. (#19816) Thanks @dalefrieswthat.
  • -
  • Discord/allowBots mention gating: add allowBots: "mentions" to only accept bot-authored messages that mention the bot. Thanks @thewilloftheshadow.
  • -
  • Agents/tool-result truncation: preserve important tail diagnostics by using head+tail truncation for oversized tool results while keeping configurable truncation options. (#20076) thanks @jlwestsr.
  • -
  • Cron/job snapshot persistence: skip backup during normalization persistence in ensureLoaded so jobs.json.bak keeps the pre-edit snapshot for recovery, while preserving backup creation on explicit user-driven writes. (#35234) Thanks @0xsline.
  • -
  • CLI: make read-only SecretRef status flows degrade safely (#37023) thanks @joshavant.
  • -
  • Tools/Diffs guidance: restore a short system-prompt hint for enabled diffs while keeping the detailed instructions in the companion skill, so diffs usage guidance stays out of user-prompt space. (#36904) thanks @gumadeiras.
  • -
  • Tools/Diffs guidance loading: move diffs usage guidance from unconditional prompt-hook injection to the plugin companion skill path, reducing unrelated-turn prompt noise while keeping diffs tool behavior unchanged. (#32630) thanks @sircrumpet.
  • -
  • Docs/Web search: remove outdated Brave free-tier wording and replace prescriptive AI ToS guidance with neutral compliance language in Brave setup docs. (#26860) Thanks @HenryLoenwind.
  • -
  • Config/Compaction safeguard tuning: expose agents.defaults.compaction.recentTurnsPreserve and quality-guard retry knobs through the validated config surface and embedded-runner wiring, with regression coverage for real config loading and schema metadata. (#25557) thanks @rodrigouroz.
  • -
  • iOS/App Store Connect release prep: align iOS bundle identifiers under ai.openclaw.client, refresh Watch app icons, add Fastlane metadata/screenshot automation, and support Keychain-backed ASC auth for uploads. (#38936) Thanks @ngutman.
  • -
  • Mattermost/model picker: add Telegram-style interactive provider/model browsing for /oc_model and /oc_models, fix picker callback updates, and emit a normal confirmation reply when a model is selected. (#38767) thanks @mukhtharcm.
  • -
  • Docker/multi-stage build: restructure Dockerfile as a multi-stage build to produce a minimal runtime image without build tools, source code, or Bun; add OPENCLAW_VARIANT=slim build arg for a bookworm-slim variant. (#38479) Thanks @sallyom.
  • -
  • Google/Gemini 3.1 Flash-Lite: add first-class google/gemini-3.1-flash-lite-preview support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
  • -
-

Breaking

-
    -
  • BREAKING: Gateway auth now requires explicit gateway.auth.mode when both gateway.auth.token and gateway.auth.password are configured (including SecretRefs). Set gateway.auth.mode to token or password before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
  • -
-

Fixes

-
    -
  • Models/MiniMax: stop advertising removed MiniMax-M2.5-Lightning in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as MiniMax-M2.5-highspeed.
  • -
  • Security/Config: fail closed when loadConfig() hits validation or read errors so invalid configs cannot silently fall back to permissive runtime defaults. (#9040) Thanks @joetomasone.
  • -
  • Memory/Hybrid search: preserve negative FTS5 BM25 relevance ordering in bm25RankToScore() so stronger keyword matches rank above weaker ones instead of collapsing or reversing scores. (#33757) Thanks @lsdcc01.
  • -
  • LINE/requireMention group gating: align inbound and reply-stage LINE group policy resolution across raw, group:, and room: keys (including account-scoped group config), preserve plugin-backed reply-stage fallback behavior, and add regression coverage for prefixed-only group/room config plus reply-stage policy resolution. (#35847) Thanks @kirisame-wang.
  • -
  • Onboarding/local setup: default unset local tools.profile to coding instead of messaging, restoring file/runtime tools for fresh local installs while preserving explicit user-set profiles. (from #38241, overlap with #34958) Thanks @cgdusek.
  • -
  • Gateway/Telegram stale-socket restart guard: only apply stale-socket restarts to channels that publish event-liveness timestamps, preventing Telegram providers from being misclassified as stale solely due to long uptime and avoiding restart/pairing storms after upgrade. (openclaw#38464)
  • -
  • Onboarding/headless Linux daemon probe hardening: treat systemctl --user is-enabled probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.
  • -
  • Memory/QMD mcporter Windows spawn hardening: when mcporter.cmd launch fails with spawn EINVAL, retry via bare mcporter shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.
  • -
  • Tools/web_search Brave language-code validation: align search_lang handling with Brave-supported codes (including zh-hans, zh-hant, en-gb, and pt-br), map common alias inputs (zh, ja) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.
  • -
  • Models/openai-completions streaming compatibility: force compat.supportsUsageInStreaming=false for non-native OpenAI-compatible endpoints during model normalization, preventing usage-only stream chunks from triggering choices[0] parser crashes in provider streams. (#8714) Thanks @nonanon1.
  • -
  • Tools/xAI native web-search collision guard: drop OpenClaw web_search from tool registration when routing to xAI/Grok model providers (including OpenRouter x-ai/*) to avoid duplicate tool-name request failures against provider-native web_search. (#14749) Thanks @realsamrat.
  • -
  • TUI/token copy-safety rendering: treat long credential-like mixed alphanumeric tokens (including quoted forms) as copy-sensitive in render sanitization so formatter hard-wrap guards no longer inject visible spaces into auth-style values before display. (#26710) Thanks @jasonthane.
  • -
  • WhatsApp/self-chat response prefix fallback: stop forcing "[openclaw]" as the implicit outbound response prefix when no identity name or response prefix is configured, so blank/default prefix settings no longer inject branding text unexpectedly in self-chat flows. (#27962) Thanks @ecanmor.
  • -
  • Memory/QMD search result decoding: accept qmd search hits that only include file URIs (for example qmd://collection/path.md) without docid, resolve them through managed collection roots, and keep multi-collection results keyed by file fallback so valid QMD hits no longer collapse to empty memory_search output. (#28181) Thanks @0x76696265.
  • -
  • Memory/QMD collection-name conflict recovery: when qmd collection add fails because another collection already occupies the same path + pattern, detect the conflicting collection from collection list, remove it, and retry add so agent-scoped managed collections are created deterministically instead of being silently skipped; also add warning-only fallback when qmd metadata is unavailable to avoid destructive guesses. (#25496) Thanks @Ramsbaby.
  • -
  • Slack/app_mention race dedupe: when app_mention dispatch wins while same-ts message prepare is still in-flight, suppress the later message dispatch so near-simultaneous Slack deliveries do not produce duplicate replies; keep single-retry behavior and add regression coverage for both dropped and successful message-prepare outcomes. (#37033) Thanks @Takhoffman.
  • -
  • Gateway/chat streaming tool-boundary text retention: merge assistant delta segments into per-run chat buffers so pre-tool text is preserved in live chat deltas/finals when providers emit post-tool assistant segments as non-prefix snapshots. (#36957) Thanks @Datyedyeguy.
  • -
  • TUI/model indicator freshness: prevent stale session snapshots from overwriting freshly patched model selection (and reset per-session freshness when switching session keys) so /model updates reflect immediately instead of lagging by one or more commands. (#21255) Thanks @kowza.
  • -
  • TUI/final-error rendering fallback: when a chat final event has no renderable assistant content but includes envelope errorMessage, render the formatted error text instead of collapsing to "(no output)", preserving actionable failure context in-session. (#14687) Thanks @Mquarmoc.
  • -
  • TUI/session-key alias event matching: treat chat events whose session keys are canonical aliases (for example agent::main vs main) as the same session while preserving cross-agent isolation, so assistant replies no longer disappear or surface in another terminal window due to strict key-form mismatch. (#33937) Thanks @yjh1412.
  • -
  • OpenAI Codex OAuth/login parity: keep openclaw models auth login --provider openai-codex on the built-in path even without provider plugins, preserve Pi-generated authorize URLs without local scope rewriting, and stop validating successful Codex sign-ins against the public OpenAI Responses API after callback. (#37558; follow-up to #36660 and #24720) Thanks @driesvints, @Skippy-Gunboat, and @obviyus.
  • -
  • Agents/config schema lookup: add gateway tool action config.schema.lookup so agents can inspect one config path at a time before edits without loading the full schema into prompt context. (#37266) Thanks @gumadeiras.
  • -
  • Onboarding/API key input hardening: strip non-Latin1 Unicode artifacts from normalized secret input (while preserving Latin-1 content and internal spaces) so malformed copied API keys cannot trigger HTTP header ByteString construction crashes; adds regression coverage for shared normalization and MiniMax auth header usage. (#24496) Thanks @fa6maalassaf.
  • -
  • Kimi Coding/Anthropic tools compatibility: normalize anthropic-messages tool payloads to OpenAI-style tools[].function + compatible tool_choice when targeting Kimi Coding endpoints, restoring tool-call workflows that regressed after v2026.3.2. (#37038) Thanks @mochimochimochi-hub.
  • -
  • Heartbeat/workspace-path guardrails: append explicit workspace HEARTBEAT.md path guidance (and docs/heartbeat.md avoidance) to heartbeat prompts so heartbeat runs target workspace checklists reliably across packaged install layouts. (#37037) Thanks @stofancy.
  • -
  • Subagents/kill-complete announce race: when a late subagent-complete lifecycle event arrives after an earlier kill marker, clear stale kill suppression/cleanup flags and re-run announce cleanup so finished runs no longer get silently swallowed. (#37024) Thanks @cmfinlan.
  • -
  • Agents/tool-result cleanup timeout hardening: on embedded runner teardown idle timeouts, clear pending tool-call state without persisting synthetic missing tool result entries, preventing timeout cleanups from poisoning follow-up turns; adds regression coverage for timeout clear-vs-flush behavior. (#37081) Thanks @Coyote-Den.
  • -
  • Agents/openai-completions stream timeout hardening: ensure runtime undici global dispatchers use extended streaming body/header timeouts (including env-proxy dispatcher mode) before embedded runs, reducing forced mid-stream terminated failures on long generations; adds regression coverage for dispatcher selection and idempotent reconfiguration. (#9708) Thanks @scottchguard.
  • -
  • Agents/fallback cooldown probe execution: thread explicit rate-limit cooldown probe intent from model fallback into embedded runner auth-profile selection so same-provider fallback attempts can actually run when all profiles are cooldowned for rate_limit (instead of failing pre-run as No available auth profile), while preserving default cooldown skip behavior and adding regression tests at both fallback and runner layers. (#13623) Thanks @asfura.
  • -
  • Cron/OpenAI Codex OAuth refresh hardening: when openai-codex token refresh fails specifically on account-id extraction, reuse the cached access token instead of failing the run immediately, with regression coverage to keep non-Codex and unrelated refresh failures unchanged. (#36604) Thanks @laulopezreal.
  • -
  • TUI/session isolation for /new: make /new allocate a unique tui- session key instead of resetting the shared agent session, so multiple TUI clients on the same agent stop receiving each other’s replies; also sanitize /new and /reset failure text before rendering in-terminal. Landed from contributor PR #39238 by @widingmarcus-cyber. Thanks @widingmarcus-cyber.
  • -
  • Synology Chat/rate-limit env parsing: honor SYNOLOGY_RATE_LIMIT=0 as an explicit value while still falling back to the default limit for malformed env values instead of partially parsing them. Landed from contributor PR #39197 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Voice-call/OpenAI Realtime STT config defaults: honor explicit vadThreshold: 0 and silenceDurationMs: 0 instead of silently replacing them with defaults. Landed from contributor PR #39196 by @scoootscooob. Thanks @scoootscooob.
  • -
  • Voice-call/OpenAI TTS speed config: honor explicit speed: 0 instead of silently replacing it with the default speed. Landed from contributor PR #39318 by @ql-wade. Thanks @ql-wade.
  • -
  • launchd/runtime PID parsing: reject pid <= 0 from launchctl print so the daemon state parser no longer treats kernel/non-running sentinel values as real process IDs. Landed from contributor PR #39281 by @mvanhorn. Thanks @mvanhorn.
  • -
  • Cron/file permission hardening: enforce owner-only (0600) cron store/backup/run-log files and harden cron store + run-log directories to 0700, including pre-existing directories from older installs. (#36078) Thanks @aerelune.
  • -
  • Gateway/remote WS break-glass hostname support: honor OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1 for ws:// hostname URLs (not only private IP literals) across onboarding validation and runtime gateway connection checks, while still rejecting public IP literals and non-unicast IPv6 endpoints. (#36930) Thanks @manju-rn.
  • -
  • Routing/binding lookup scalability: pre-index route bindings by channel/account and avoid full binding-list rescans on channel-account cache rollover, preventing multi-second resolveAgentRoute stalls in large binding configurations. (#36915) Thanks @songchenghao.
  • -
  • Browser/session cleanup: track browser tabs opened by session-scoped browser tool runs and close tracked tabs during sessions.reset/sessions.delete runtime cleanup, preventing orphaned tabs and unbounded browser memory growth after session teardown. (#36666) Thanks @Harnoor6693.
  • -
  • Plugin/hook install rollback hardening: stage installs under the canonical install base, validate and run dependency installs before publish, and restore updates by rename instead of deleting the target path, reducing partial-replace and symlink-rebind risk during install failures.
  • -
  • Slack/local file upload allowlist parity: propagate mediaLocalRoots through the Slack send action pipeline so workspace-rooted attachments pass assertLocalMediaAllowed checks while non-allowlisted paths remain blocked. (synthesis: #36656; overlap considered from #36516, #36496, #36493, #36484, #32648, #30888) Thanks @2233admin.
  • -
  • Agents/compaction safeguard pre-check: skip embedded compaction before entering the Pi SDK when a session has no real conversation messages, avoiding unnecessary LLM API calls on idle sessions. (#36451) thanks @Sid-Qin.
  • -
  • Config/schema cache key stability: build merged schema cache keys with incremental hashing to avoid large single-string serialization and prevent RangeError: Invalid string length on high-cardinality plugin/channel metadata. (#36603) Thanks @powermaster888.
  • -
  • iMessage/cron completion announces: strip leaked inline reply tags (for example [[reply_to:6100]]) from user-visible completion text so announcement deliveries do not expose threading metadata. (#24600) Thanks @vincentkoc.
  • -
  • Control UI/iMessage duplicate reply routing: keep internal webchat turns on dispatcher delivery (instead of origin-channel reroute) so Control UI chats do not duplicate replies into iMessage, while preserving webchat-provider relayed routing for external surfaces. Fixes #33483. Thanks @alicexmolt.
  • -
  • Sessions/daily reset transcript archival: archive prior transcript files during stale-session scheduled/daily resets by capturing the previous session entry before rollover, preventing orphaned transcript files on disk. (#35493) Thanks @byungsker.
  • -
  • Feishu/group slash command detection: normalize group mention wrappers before command-authorization probing so mention-prefixed commands (for example @Bot/model and @Bot /reset) are recognized as gateway commands instead of being forwarded to the agent. (#35994) Thanks @liuxiaopai-ai.
  • -
  • Control UI/auth token separation: keep the shared gateway token in browser auth validation while reserving cached device tokens for signed device payloads, preventing false device token mismatch disconnects after restart/rotation. Landed from contributor PR #37382 by @FradSer. Thanks @FradSer.
  • -
  • Gateway/browser auth reconnect hardening: stop counting missing token/password submissions as auth rate-limit failures, and stop auto-reconnecting Control UI clients on non-recoverable auth errors so misconfigured browser tabs no longer lock out healthy sessions. Landed from contributor PR #38725 by @ademczuk. Thanks @ademczuk.
  • -
  • Gateway/service token drift repair: stop persisting shared auth tokens into installed gateway service units, flag stale embedded service tokens for reinstall, and treat tokenless service env as canonical so token rotation/reboot flows stay aligned with config/env resolution. Landed from contributor PR #28428 by @l0cka. Thanks @l0cka.
  • -
  • Control UI/agents-page selection: keep the edited agent selected after saving agent config changes and reloading the agents list, so /agents no longer snaps back to the default agent. Landed from contributor PR #39301 by @MumuTW. Thanks @MumuTW.
  • -
  • Gateway/auth follow-up hardening: preserve systemd EnvironmentFile= precedence/source provenance in daemon audits and doctor repairs, block shared-password override flows from piggybacking cached device tokens, and fail closed when config-first gateway SecretRefs cannot resolve. Follow-up to #39241.
  • -
  • Agents/context pruning: guard assistant thinking/text char estimation against malformed blocks (missing thinking/text strings or null entries) so pruning no longer crashes with malformed provider content. (openclaw#35146) thanks @Sid-Qin.
  • -
  • Agents/transcript policy: set preserveSignatures to Anthropic-only handling in resolveTranscriptPolicy so Anthropic thinking signatures are preserved while non-Anthropic providers remain unchanged. (#32813) thanks @Sid-Qin.
  • -
  • Agents/schema cleaning: detect Venice + Grok model IDs as xAI-proxied targets so unsupported JSON Schema keywords are stripped before requests, preventing Venice/Grok Invalid arguments failures. (openclaw#35355) thanks @Sid-Qin.
  • -
  • Skills/native command deduplication: centralize skill command dedupe by canonical skillName in listSkillCommandsForAgents so duplicate suffixed variants (for example _2) are no longer surfaced across interfaces outside Discord. (#27521) thanks @shivama205.
  • -
  • Agents/xAI tool-call argument decoding: decode HTML-entity encoded xAI/Grok tool-call argument values (&, ", <, >, numeric entities) before tool execution so commands with shell operators and quotes no longer fail with parse errors. (#35276) Thanks @Sid-Qin.
  • -
  • Linux/WSL2 daemon install hardening: add regression coverage for WSL environment detection, WSL-specific systemd guidance, and systemctl --user is-enabled failure paths so WSL2/headless onboarding keeps treating bus-unavailable probes as non-fatal while preserving real permission errors. Related: #36495. Thanks @vincentkoc.
  • -
  • Linux/systemd status and degraded-session handling: treat degraded-but-reachable systemctl --user status results as available, preserve early errors for truly unavailable user-bus cases, and report externally managed running services as running instead of not installed. Thanks @vincentkoc.
  • -
  • Agents/thinking-tag promotion hardening: guard promoteThinkingTagsToBlocks against malformed assistant content entries (null/undefined) before block.type reads so malformed provider payloads no longer crash session processing while preserving pass-through behavior. (#35143) thanks @Sid-Qin.
  • -
  • Gateway/Control UI version reporting: align runtime and browser client version metadata to avoid dev placeholders, wait for bootstrap version before first UI websocket connect, and only forward bootstrap serverVersion to same-origin gateway targets to prevent cross-target version leakage. (from #35230, #30928, #33928) Thanks @Sid-Qin, @joelnishanth, and @MoerAI.
  • -
  • Control UI/markdown parser crash fallback: catch marked.parse() failures and fall back to escaped plain-text
     rendering so malformed recursive markdown no longer crashes Control UI session rendering on load. (#36445) Thanks @BinHPdev.
  • -
  • Control UI/markdown fallback regression coverage: add explicit regression assertions for parser-error fallback behavior so malformed markdown no longer risks reintroducing hard-crash rendering paths in future markdown/parser upgrades. (#36445) Thanks @BinHPdev.
  • -
  • Web UI/config form: treat additionalProperties: true object schemas as editable map entries instead of unsupported fields so Accounts-style maps stay editable in form mode. (#35380, supersedes #32072) Thanks @stakeswky and @liuxiaopai-ai.
  • -
  • Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread message.reply routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
  • -
  • Feishu/group mention detection: carry startup-probed bot display names through monitor dispatch so requireMention checks compare against current bot identity instead of stale config names, fixing missed @bot handling in groups while preserving multi-bot false-positive guards. (#36317, #34271) Thanks @liuxiaopai-ai.
  • -
  • Security/dependency audit: patch transitive Hono vulnerabilities by pinning hono to 4.12.5 and @hono/node-server to 1.19.10 in production resolution paths. Thanks @shakkernerd.
  • -
  • Security/dependency audit: bump tar to 7.5.10 (from 7.5.9) to address the high-severity hardlink path traversal advisory (GHSA-qffp-2rhf-9h96). Thanks @shakkernerd.
  • -
  • Cron/announce delivery robustness: bypass pending-descendant announce guards for cron completion sends, ensure named-agent announce routes have outbound session entries, and fall back to direct delivery only when an announce send was actually attempted and failed. (from #35185, #32443, #34987) Thanks @Sid-Qin, @scoootscooob, and @bmendonca3.
  • -
  • Cron/announce best-effort fallback: run direct outbound fallback after attempted announce failures even when delivery is configured as best-effort, so Telegram cron sends are not left as attempted-but-undelivered after cron announce delivery failed warnings.
  • -
  • Auto-reply/system events: restore runtime system events to the message timeline (System: lines), preserve think-hint parsing with prepended events, and carry events into deferred followup/collect/steer-backlog prompts to keep cache behavior stable without dropping queued metadata. (#34794) Thanks @anisoptera.
  • -
  • Security/audit account handling: avoid prototype-chain account IDs in audit validation by using own-property checks for accounts. (#34982) Thanks @HOYALIM.
  • -
  • Cron/restart catch-up semantics: replay interrupted recurring jobs and missed immediate cron slots on startup without replaying interrupted one-shot jobs, with guarded missed-slot probing to avoid malformed-schedule startup aborts and duplicate-trigger drift after restart. (from #34466, #34896, #34625, #33206) Thanks @dunamismax, @dsantoreis, @Octane0411, and @Sid-Qin.
  • -
  • Venice/provider onboarding hardening: align per-model Venice completion-token limits with discovery metadata, clamp untrusted discovery values to safe bounds, sync the static Venice fallback catalog with current live model metadata, and disable tool wiring for Venice models that do not support function calling so default Venice setups no longer fail with max_completion_tokens or unsupported-tools 400s. Fixes #38168. Thanks @Sid-Qin, @powermaster888 and @vincentkoc.
  • -
  • Agents/session usage tracking: preserve accumulated usage metadata on embedded Pi runner error exits so failed turns still update session totalTokens from real usage instead of stale prior values. (#34275) thanks @RealKai42.
  • -
  • Slack/reaction thread context routing: carry Slack native DM channel IDs through inbound context and threading tool resolution so reaction targets resolve consistently for DM To=user:* sessions (including toolContext.currentChannelId fallback behavior). (from #34831; overlaps #34440, #34502, #34483, #32754) Thanks @dunamismax.
  • -
  • Subagents/announce completion scoping: scope nested direct-child completion aggregation to the current requester run window, harden frozen completion capture for deterministic descendant synthesis, and route completion announce delivery through parent-agent announce turns with provenance-aware internal events. (#35080) Thanks @tyler6204.
  • -
  • Nodes/system.run approval hardening: use explicit argv-mutation signaling when regenerating prepared rawCommand, and cover the system.run.prepare -> system.run handoff so direct PATH-based nodes.run commands no longer fail with rawCommand does not match command. (#33137) thanks @Sid-Qin.
  • -
  • Models/custom provider headers: propagate models.providers..headers across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin.
  • -
  • Ollama/remote provider auth fallback: synthesize a local runtime auth key for explicitly configured models.providers.ollama entries that omit apiKey, so remote Ollama endpoints run without requiring manual dummy-key setup while preserving env/profile/config key precedence and missing-config failures. (#11283) Thanks @cpreecs.
  • -
  • Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic.
  • -
  • Ollama/compaction and summarization: register custom api: "ollama" handling for compaction, branch-style internal summarization, and TTS text summarization on current main, so native Ollama models no longer fail with No API provider registered for api: ollama outside the main run loop. Thanks @JaviLib.
  • -
  • Daemon/systemd install robustness: treat systemctl --user is-enabled exit-code-4 not-found responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with systemctl is-enabled unavailable. (#33634) Thanks @Yuandiaodiaodiao.
  • -
  • Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to agent:main. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc.
  • -
  • Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native markdown_text in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931)
  • -
  • Gateway/HTTP tools invoke media compatibility: preserve raw media payload access for direct /tools/invoke clients by allowing media nodes invoke commands only in HTTP tool context, while keeping agent-context media invoke blocking to prevent base64 prompt bloat. (#34365) Thanks @obviyus.
  • -
  • Security/archive ZIP hardening: extract ZIP entries via same-directory temp files plus atomic rename, then re-open and reject post-rename hardlink alias races outside the destination root.
  • -
  • Agents/Nodes media outputs: add dedicated photos_latest action handling, block media-returning nodes invoke commands, keep metadata-only camera.list invoke allowed, and normalize empty photos_latest results to a consistent response shape to prevent base64 context bloat. (#34332) Thanks @obviyus.
  • -
  • TUI/session-key canonicalization: normalize openclaw tui --session values to lowercase so uppercase session names no longer drop real-time streaming updates due to gateway/TUI key mismatches. (#33866, #34013) thanks @lynnzc.
  • -
  • iMessage/echo loop hardening: strip leaked assistant-internal scaffolding from outbound iMessage replies, drop reflected assistant-content messages before they re-enter inbound processing, extend echo-cache text retention for delayed reflections, and suppress repeated loop traffic before it amplifies into queue overflow. (#33295) Thanks @joelnishanth.
  • -
  • Skills/workspace boundary hardening: reject workspace and extra-dir skill roots or SKILL.md files whose realpath escapes the configured source root, and skip syncing those escaped skills into sandbox workspaces.
  • -
  • Outbound/send config threading: pass resolved SecretRef config through outbound adapters and helper send paths so send flows do not reload unresolved runtime config. (#33987) Thanks @joshavant.
  • -
  • gateway: harden shared auth resolution across systemd, discord, and node host (#39241) Thanks @joshavant.
  • -
  • Secrets/models.json persistence hardening: keep SecretRef-managed api keys + headers from persisting in generated models.json, expand audit/apply coverage, and harden marker handling/serialization. (#38955) Thanks @joshavant.
  • -
  • Sessions/subagent attachments: remove attachments[].content.maxLength from sessions_spawn schema to avoid llama.cpp GBNF repetition overflow, and preflight UTF-8 byte size before buffer allocation while keeping runtime file-size enforcement unchanged. (#33648) Thanks @anisoptera.
  • -
  • Runtime/tool-state stability: recover from dangling Anthropic tool_use after compaction, serialize long-running Discord handler runs without blocking new inbound events, and prevent stale busy snapshots from suppressing stuck-channel recovery. (from #33630, #33583) Thanks @kevinWangSheng and @theotarr.
  • -
  • ACP/Discord startup hardening: clean up stuck ACP worker children on gateway restart, unbind stale ACP thread bindings during Discord startup reconciliation, and add per-thread listener watchdog timeouts so wedged turns cannot block later messages. (#33699) Thanks @dutifulbob.
  • -
  • Extensions/media local-root propagation: consistently forward mediaLocalRoots through extension sendMedia adapters (Google Chat, Slack, iMessage, Signal, WhatsApp), preserving non-local media behavior while restoring local attachment resolution from configured roots. Synthesis of #33581, #33545, #33540, #33536, #33528. Thanks @bmendonca3.
  • -
  • Gateway/plugin HTTP auth hardening: require gateway auth when any overlapping matched route needs it, block mixed-auth fallthrough at dispatch, and reject mixed-auth exact/prefix route overlaps during plugin registration.
  • -
  • Feishu/video media send contract: keep mp4-like outbound payloads on msg_type: "media" (including reply and reply-in-thread paths) so videos render as media instead of degrading to file-link behavior, while preserving existing non-video file subtype handling. (from #33720, #33808, #33678) Thanks @polooooo, @dingjianrui, and @kevinWangSheng.
  • -
  • Gateway/security default response headers: add Permissions-Policy: camera=(), microphone=(), geolocation=() to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.
  • -
  • Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into openclaw/plugin-sdk/core and openclaw/plugin-sdk/telegram, and preserve api.runtime reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
  • -
  • Plugins/startup performance: reduce bursty plugin discovery/manifest overhead with short in-process caches, skip importing bundled memory plugins that are disabled by slot selection, and speed legacy root openclaw/plugin-sdk compatibility via runtime root-alias routing while preserving backward compatibility. Thanks @gumadeiras.
  • -
  • Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
  • -
  • Gateway/password CLI hardening: add openclaw gateway run --password-file, warn when inline --password is used because it can leak via process listings, and document env/file-backed password input as the preferred startup path. Fixes #27948. Thanks @vibewrk and @vincentkoc.
  • -
  • Config/heartbeat legacy-path handling: auto-migrate top-level heartbeat into agents.defaults.heartbeat (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
  • -
  • Plugins/SDK subpath parity: expand plugin SDK subpaths across bundled channels/extensions (Discord, Slack, Signal, iMessage, WhatsApp, LINE, and bundled companion plugins), with build/export/type/runtime wiring so scoped imports resolve consistently in source and dist while preserving compatibility. (#33737) thanks @gumadeiras.
  • -
  • Google/Gemini Flash model selection: switch built-in gemini-flash defaults and docs/examples from the nonexistent google/gemini-3.1-flash-preview ID to the working google/gemini-3-flash-preview, while normalizing legacy OpenClaw config that still uses the old Flash 3.1 alias.
  • -
  • Plugins/bundled scoped-import migration: migrate bundled plugins from monolithic openclaw/plugin-sdk imports to scoped subpaths (or openclaw/plugin-sdk/core) across registration and startup-sensitive runtime files, add CI/release guardrails to prevent regressions, and keep root openclaw/plugin-sdk support for external/community plugins. Thanks @gumadeiras.
  • -
  • Routing/session duplicate suppression synthesis: align shared session delivery-context inheritance, channel-paired route-field merges, and reply-surface target matching so dmScope=main turns avoid cross-surface duplicate replies while thread-aware forwarding keeps intended routing semantics. (from #33629, #26889, #17337, #33250) Thanks @Yuandiaodiaodiao, @kevinwildenradt, @Glucksberg, and @bmendonca3.
  • -
  • Routing/legacy session route inheritance: preserve external route metadata inheritance for legacy channel session keys (agent::: and ...:thread:) so chat.send does not incorrectly fall back to webchat when valid delivery context exists. Follow-up to #33786.
  • -
  • Routing/legacy route guard tightening: require legacy session-key channel hints to match the saved delivery channel before inheriting external routing metadata, preventing custom namespaced keys like agent::work: from inheriting stale non-webchat routes.
  • -
  • Gateway/internal client routing continuity: prevent webchat/TUI/UI turns from inheriting stale external reply routes by requiring explicit deliver: true for external delivery, keeping main-session external inheritance scoped to non-Webchat/UI clients, and honoring configured session.mainKey when identifying main-session continuity. (from #35321, #34635, #35356) Thanks @alexyyyander and @Octane0411.
  • -
  • Security/auth labels: remove token and API-key snippets from user-facing auth status labels so /status and /models do not expose credential fragments. (#33262) thanks @cu1ch3n.
  • -
  • Models/MiniMax portal vision routing: add MiniMax-VL-01 to the minimax-portal provider, route portal image understanding through the MiniMax VLM endpoint, and align media auto-selection plus Telegram sticker description with the shared portal image provider path. (#33953) Thanks @tars90percent.
  • -
  • Auth/credential semantics: align profile eligibility + probe diagnostics with SecretRef/expiry rules and harden browser download atomic writes. (#33733) thanks @joshavant.
  • -
  • Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown gateway.nodes.denyCommands entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.
  • -
  • Agents/overload failover handling: classify overloaded provider failures separately from rate limits/status timeouts, add short overload backoff before retry/failover, record overloaded prompt/assistant failures as transient auth-profile cooldowns (with probeable same-provider fallback) instead of treating them like persistent auth/billing failures, and keep one-shot cron retry classification aligned so overloaded fallback summaries still count as transient retries.
  • -
  • Docs/security hardening guidance: document Docker DOCKER-USER + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
  • -
  • Docs/security threat-model links: replace relative .md links with Mintlify-compatible root-relative routes in security docs to prevent broken internal navigation. (#27698) thanks @clawdoo.
  • -
  • Plugins/Update integrity drift: avoid false integrity drift prompts when updating npm-installed plugins from unpinned specs, while keeping drift checks for exact pinned versions. (#37179) Thanks @vincentkoc.
  • -
  • iOS/Voice timing safety: guard system speech start/finish callbacks to the active utterance to avoid misattributed start events during rapid stop/restart cycles. (#33304) thanks @mbelinky; original implementation direction by @ngutman.
  • -
  • Gateway/chat.send command scopes: require operator.admin for persistent /config set|unset writes routed through gateway chat clients while keeping /config show available to normal write-scoped operator clients, preserving messaging-channel config command behavior without widening RPC write scope into admin config mutation. Thanks @tdjackey for reporting.
  • -
  • iOS/Talk incremental speech pacing: allow long punctuation-free assistant chunks to start speaking at safe whitespace boundaries so voice responses begin sooner instead of waiting for terminal punctuation. (#33305) thanks @mbelinky; original implementation by @ngutman.
  • -
  • iOS/Watch reply reliability: make watch session activation waiters robust under concurrent requests so status/send calls no longer hang intermittently, and align delegate callbacks with Swift 6 actor safety. (#33306) thanks @mbelinky; original implementation by @Rocuts.
  • -
  • Docs/tool-loop detection config keys: align docs/tools/loop-detection.md examples and field names with the current tools.loopDetection schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
  • -
  • Gateway/session agent discovery: include disk-scanned agent IDs in listConfiguredAgentIds even when agents.list is configured, so disk-only/ACP agent sessions remain visible in gateway session aggregation and listings. (#32831) thanks @Sid-Qin.
  • -
  • Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
  • -
  • Discord/Agent-scoped media roots: pass mediaLocalRoots through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
  • -
  • Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.
  • -
  • Discord/thread session lifecycle: reset thread-scoped sessions when a thread is archived so reopening a thread starts fresh without deleting transcript history. Thanks @thewilloftheshadow.
  • -
  • Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
  • -
  • Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
  • -
  • ACP/sandbox spawn parity: block /acp spawn from sandboxed requester sessions with the same host-runtime guard already enforced for sessions_spawn({ runtime: "acp" }), preserving non-sandbox ACP flows while closing the command-path policy gap. Thanks @patte.
  • -
  • Discord/config SecretRef typing: align Discord account token config typing with SecretInput so SecretRef tokens typecheck. (#32490) Thanks @scoootscooob.
  • -
  • Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.
  • -
  • Discord/voice decoder fallback: drop the native Opus dependency and use opusscript for voice decoding to avoid native-opus installs. Thanks @thewilloftheshadow.
  • -
  • Discord/auto presence health signal: add runtime availability-driven presence updates plus connected-state reporting to improve health monitoring and operator visibility. (#33277) Thanks @thewilloftheshadow.
  • -
  • HEIC image inputs: accept HEIC/HEIF input_image sources in Gateway HTTP APIs, normalize them to JPEG before provider delivery, and document the expanded default MIME allowlist. Thanks @vincentkoc.
  • -
  • Gateway/HEIC input follow-up: keep non-HEIC input_image MIME handling unchanged, make HEIC tests hermetic, and enforce chat-completions maxTotalImageBytes against post-normalization image payload size. Thanks @vincentkoc.
  • -
  • Telegram/draft-stream boundary stability: materialize DM draft previews at assistant-message/tool boundaries, serialize lane-boundary callbacks before final delivery, and scope preview cleanup to the active preview so multi-step Telegram streams no longer lose, overwrite, or leave stale preview bubbles. (#33842) Thanks @ngutman.
  • -
  • Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
  • -
  • Telegram/DM draft final delivery: materialize text-only sendMessageDraft previews into one permanent final message and skip duplicate final payload sends, while preserving fallback behavior when materialization fails. (#34318) Thanks @Brotherinlaw-13.
  • -
  • Telegram/DM draft duplicate display: clear stale DM draft previews after materializing the real final message, including threadless fallback when DM topic lookup fails, so partial streaming no longer briefly shows duplicate replies. (#36746) Thanks @joelnishanth.
  • -
  • Telegram/draft preview boundary + silent-token reliability: stabilize answer-lane message boundaries across late-partial/message-start races, preserve/reset finalized preview state at the correct boundaries, and suppress NO_REPLY lead-fragment leaks without broad heartbeat-prefix false positives. (#33169) Thanks @obviyus.
  • -
  • Telegram/native commands commands.allowFrom precedence: make native Telegram commands honor commands.allowFrom as the command-specific authorization source, including group chats, instead of falling back to channel sender allowlists. (#28216) Thanks @toolsbybuddy and @vincentkoc.
  • -
  • Telegram/groupAllowFrom sender-ID validation: restore sender-only runtime validation so negative chat/group IDs remain invalid entries instead of appearing accepted while still being unable to authorize group access. (#37134) Thanks @qiuyuemartin-max and @vincentkoc.
  • -
  • Telegram/native group command auth: authorize native commands in groups and forum topics against groupAllowFrom and per-group/topic sender overrides, while keeping auth rejection replies in the originating topic thread. (#39267) Thanks @edwluo.
  • -
  • Telegram/named-account DMs: restore non-default-account DM routing when a named Telegram account falls back to the default agent by keeping groups fail-closed but deriving a per-account session key for DMs, including identity-link canonicalization and regression coverage for account isolation. (from #32426; fixes #32351) Thanks @chengzhichao-xydt.
  • -
  • Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
  • -
  • Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
  • -
  • Discord/chunk delivery reliability: preserve chunk ordering when using a REST client and retry chunk sends on 429/5xx using account retry settings. (#33226) Thanks @thewilloftheshadow.
  • -
  • Discord/mention handling: add id-based mention formatting + cached rewrites, resolve inbound mentions to display names, and add optional ignoreOtherMentions gating (excluding @everyone/@here). (#33224) Thanks @thewilloftheshadow.
  • -
  • Discord/media SSRF allowlist: allow Discord CDN hostnames (including wildcard domains) in inbound media SSRF policy to prevent proxy/VPN fake-ip blocks. (#33275) Thanks @thewilloftheshadow.
  • -
  • Telegram/device pairing notifications: auto-arm one-shot notify on /pair qr, auto-ping on new pairing requests, and add manual fallback via /pair approve latest if the ping does not arrive. (#33299) thanks @mbelinky.
  • -
  • Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
  • -
  • macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (wss://.ts.net) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
  • -
  • iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.
  • -
  • iOS/Concurrency stability: replace risky shared-state access in camera and gateway connection paths with lock-protected access patterns to reduce crash risk under load. (#33241) thanks @mbelinky.
  • -
  • iOS/Security guardrails: limit production API-key sourcing to app config and make deep-link confirmation prompts safer by coalescing queued requests instead of silently dropping them. (#33031) thanks @mbelinky.
  • -
  • iOS/TTS playback fallback: keep voice playback resilient by switching from PCM to MP3 when provider format support is unavailable, while avoiding sticky fallback on generic local playback errors. (#33032) thanks @mbelinky.
  • -
  • Plugin outbound/text-only adapter compatibility: allow direct-delivery channel plugins that only implement sendText (without sendMedia) to remain outbound-capable, gracefully fall back to text delivery for media payloads when sendMedia is absent, and fail explicitly for media-only payloads with no text fallback. (#32788) thanks @liuxiaopai-ai.
  • -
  • Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add openclaw doctor warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
  • -
  • Telegram/plugin outbound hook parity: run message_sending + message_sent in Telegram reply delivery, include reply-path hook metadata (mediaUrls, threadId), and report message_sent.success=false when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
  • -
  • CLI/Coding-agent reliability: switch default claude-cli non-interactive args to --permission-mode bypassPermissions, auto-normalize legacy --dangerously-skip-permissions backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. (#28610, #31149, #34055). Thanks @niceysam, @cryptomaltese and @vincentkoc.
  • -
  • Gateway/OpenAI chat completions: parse active-turn image_url content parts (including parameterized data URIs and guarded URL sources), forward them as multimodal images, accept image-only user turns, enforce per-request image-part/byte budgets, default URL-based image fetches to disabled unless explicitly enabled by config, and redact image base64 data in cache-trace/provider payload diagnostics. (#17685) Thanks @vincentkoc
  • -
  • ACP/ACPX session bootstrap: retry with sessions new when sessions ensure returns no session identifiers so ACP spawns avoid NO_SESSION/ACP_TURN_FAILED failures on affected agents. (#28786, #31338, #34055). Thanks @Sid-Qin and @vincentkoc.
  • -
  • ACP/sessions_spawn parent stream visibility: add streamTo: "parent" for runtime: "acp" to forward initial child-run progress/no-output/completion updates back into the requester session as system events (instead of direct child delivery), and emit a tail-able session-scoped relay log (.acp-stream.jsonl, returned as streamLogPath when available), improving orchestrator visibility for blocked or long-running harness turns. (#34310, #29909; reopened from #34055). Thanks @vincentkoc.
  • -
  • Agents/bootstrap truncation warning handling: unify bootstrap budget/truncation analysis across embedded + CLI runtime, /context, and openclaw doctor; add agents.defaults.bootstrapPromptTruncationWarning (off|once|always, default once) and persist warning-signature metadata so truncation warnings are consistent and deduped across turns. (#32769) Thanks @gumadeiras.
  • -
  • Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
  • -
  • Agents/Session startup date grounding: substitute YYYY-MM-DD placeholders in startup/post-compaction AGENTS context and append runtime current-time lines for /new and /reset prompts so daily-memory references resolve correctly. (#32381) Thanks @chengzhichao-xydt.
  • -
  • Agents/Compaction template heading alignment: update AGENTS template section names to Session Startup/Red Lines and keep legacy Every Session/Safety fallback extraction so post-compaction context remains intact across template versions. (#25098) thanks @echoVic.
  • -
  • Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.
  • -
  • Agents/Compaction safeguard structure hardening: require exact fallback summary headings, sanitize untrusted compaction instruction text before prompt embedding, and keep structured sections when preserving all turns. (#25555) thanks @rodrigouroz.
  • -
  • Gateway/status self version reporting: make Gateway self version in openclaw status prefer runtime VERSION (while preserving explicit OPENCLAW_VERSION override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai.
  • -
  • Memory/QMD index isolation: set QMD_CONFIG_DIR alongside XDG_CONFIG_HOME so QMD config state stays per-agent despite upstream XDG handling bugs, preventing cross-agent collection indexing and excess disk/CPU usage. (#27028) thanks @HenryLoenwind.
  • -
  • Memory/QMD collection safety: stop destructive collection rebinds when QMD collection list only reports names without path metadata, preventing memory search from dropping existing collections if re-add fails. (#36870) Thanks @Adnannnnnnna.
  • -
  • Memory/QMD duplicate-document recovery: detect UNIQUE constraint failed: documents.collection, documents.path update failures, rebuild managed collections once, and retry update so periodic QMD syncs recover instead of failing every run; includes regression coverage to avoid over-matching unrelated unique constraints. (#27649) Thanks @MiscMich.
  • -
  • Memory/local embedding initialization hardening: add regression coverage for transient initialization retry and mixed embedQuery + embedBatch concurrent startup to lock single-flight initialization behavior. (#15639) thanks @SubtleSpark.
  • -
  • CLI/Coding-agent reliability: switch default claude-cli non-interactive args to --permission-mode bypassPermissions, auto-normalize legacy --dangerously-skip-permissions backend overrides to the modern permission-mode form, align coding-agent + live-test docs with the non-PTY Claude path, and emit session system-event heartbeat notices when CLI watchdog no-output timeouts terminate runs. Related to #28261. Landed from contributor PRs #28610 and #31149. Thanks @niceysam, @cryptomaltese and @vincentkoc.
  • -
  • ACP/ACPX session bootstrap: retry with sessions new when sessions ensure returns no session identifiers so ACP spawns avoid NO_SESSION/ACP_TURN_FAILED failures on affected agents. Related to #28786. Landed from contributor PR #31338. Thanks @Sid-Qin and @vincentkoc.
  • -
  • LINE/auth boundary hardening synthesis: enforce strict LINE webhook authn/z boundary semantics across pairing-store account scoping, DM/group allowlist separation, fail-closed webhook auth/runtime behavior, and replay/duplication controls (including in-flight replay reservation and post-success dedupe marking). (from #26701, #26683, #25978, #17593, #16619, #31990, #26047, #30584, #18777) Thanks @bmendonca3, @davidahmann, @harshang03, @haosenwang1018, @liuxiaopai-ai, @coygeek, and @Takhoffman.
  • -
  • LINE/media download synthesis: fix file-media download handling and M4A audio classification across overlapping LINE regressions. (from #26386, #27761, #27787, #29509, #29755, #29776, #29785, #32240) Thanks @kevinWangSheng, @loiie45e, @carrotRakko, @Sid-Qin, @codeafridi, and @bmendonca3.
  • -
  • LINE/context and routing synthesis: fix group/room peer routing and command-authorization context propagation, and keep processing later events in mixed-success webhook batches. (from #21955, #24475, #27035, #28286) Thanks @lailoo, @mcaxtr, @jervyclaw, @Glucksberg, and @Takhoffman.
  • -
  • LINE/status/config/webhook synthesis: fix status false positives from snapshot/config state and accept LINE webhook HEAD probes for compatibility. (from #10487, #25726, #27537, #27908, #31387) Thanks @BlueBirdBack, @stakeswky, @loiie45e, @puritysb, and @mcaxtr.
  • -
  • LINE cleanup/test follow-ups: fold cleanup/test learnings into the synthesis review path while keeping runtime changes focused on regression fixes. (from #17630, #17289) Thanks @Clawborn and @davidahmann.
  • -
  • Mattermost/interactive buttons: add interactive button send/callback support with directory-based channel/user target resolution, and harden callbacks via account-scoped HMAC verification plus sender-scoped DM routing. (#19957) thanks @tonydehnke.
  • -
  • Feishu/groupPolicy legacy alias compatibility: treat legacy groupPolicy: "allowall" as open in both schema parsing and runtime policy checks so intended open-group configs no longer silently drop group messages when groupAllowFrom is empty. (from #36358) Thanks @Sid-Qin.
  • -
  • Mattermost/plugin SDK import policy: replace remaining monolithic openclaw/plugin-sdk imports in Mattermost mention-gating paths/tests with scoped subpaths (openclaw/plugin-sdk/compat and openclaw/plugin-sdk/mattermost) so pnpm check passes lint:plugins:no-monolithic-plugin-sdk-entry-imports on baseline. (#36480) Thanks @Takhoffman.
  • -
  • Telegram/polls: add Telegram poll action support to channel action discovery and tool/CLI poll flows, with multi-account discoverability gated to accounts that can actually execute polls (sendMessage + poll). (#36547) thanks @gumadeiras.
  • -
  • Agents/failover cooldown classification: stop treating generic cooling down text as provider rate_limit so healthy models no longer show false global cooldown/rate-limit warnings while explicit model_cooldown markers still trigger failover. (#32972) thanks @stakeswky.
  • -
  • Agents/failover service-unavailable handling: stop treating bare proxy/CDN service unavailable errors as provider overload while keeping them retryable via the timeout/failover path, so transient outages no longer show false rate-limit warnings or block fallback. (#36646) thanks @jnMetaCode.
  • -
  • Plugins/HTTP route migration diagnostics: rewrite legacy api.registerHttpHandler(...) loader failures into actionable migration guidance so doctor/plugin diagnostics point operators to api.registerHttpRoute(...) or registerPluginHttpRoute(...). (#36794) Thanks @vincentkoc
  • -
  • Doctor/Heartbeat upgrade diagnostics: warn when heartbeat delivery is configured with an implicit directPolicy so upgrades pin direct/DM behavior explicitly instead of relying on the current default. (#36789) Thanks @vincentkoc.
  • -
  • Agents/current-time UTC anchor: append a machine-readable UTC suffix alongside local Current time: lines in shared cron-style prompt contexts so agents can compare UTC-stamped workspace timestamps without doing timezone math. (#32423) thanks @jriff.
  • -
  • Ollama/local model handling: preserve explicit lower contextWindow / maxTokens overrides during merge refresh, and keep native Ollama streamed replies from surfacing fallback thinking / reasoning text once real content starts streaming. (#39292) Thanks @vincentkoc.
  • -
  • TUI/webchat command-owner scope alignment: treat internal-channel gateway sessions with operator.admin as owner-authorized in command auth, restoring cron/gateway/connector tool access for affected TUI/webchat sessions while keeping external channels on identity-based owner checks. (from #35666, #35673, #35704) Thanks @Naylenv, @Octane0411, and @Sid-Qin.
  • -
  • Discord/inbound timeout isolation: separate inbound worker timeout tracking from listener timeout budgets so queued Discord replies are no longer dropped when listener watchdog windows expire mid-run. (#36602) Thanks @dutifulbob.
  • -
  • Memory/doctor SecretRef handling: treat SecretRef-backed memory-search API keys as configured, and fail embedding setup with explicit unresolved-secret errors instead of crashing. (#36835) Thanks @joshavant.
  • -
  • Memory/flush default prompt: ban timestamped variant filenames during default memory flush runs so durable notes stay in the canonical daily memory/YYYY-MM-DD.md file. (#34951) thanks @zerone0x.
  • -
  • Agents/reply delivery timing: flush embedded Pi block replies before waiting on compaction retries so already-generated assistant replies reach channels before compaction wait completes. (#35489) thanks @Sid-Qin.
  • -
  • Agents/gateway config guidance: stop exposing config.schema through the agent gateway tool, remove prompt/docs guidance that told agents to call it, and keep agents on config.get plus config.patch/config.apply for config changes. (#7382) thanks @kakuteki.
  • -
  • Provider/KiloCode: Keep duplicate models after malformed discovery rows, and strip legacy reasoning_effort when proxy reasoning injection is skipped. (#32352) Thanks @pandemicsyn and @vincentkoc.
  • -
  • Agents/failover: classify periodic provider limit exhaustion text (for example Weekly/Monthly Limit Exhausted) as rate_limit while keeping explicit 402 Payment Required variants in billing, so failover continues without misclassifying billing-wrapped quota errors. (#33813) thanks @zhouhe-xydt.
  • -
  • Mattermost/interactive button callbacks: allow external callback base URLs and stop requiring loopback-origin requests so button clicks work when Mattermost reaches the gateway over Tailscale, LAN, or a reverse proxy. (#37543) thanks @mukhtharcm.
  • -
  • Gateway/chat.send route inheritance: keep explicit external delivery for channel-scoped sessions while preventing shared-main and other channel-agnostic webchat sessions from inheriting stale external routes, so Control UI replies stay on webchat without breaking selected channel-target sessions. (#34669) Thanks @vincentkoc.
  • -
  • Telegram/Discord media upload caps: make outbound uploads honor channel mediaMaxMb config, raise Telegram's default media cap to 100MB, and remove MIME fallback limits that kept some Telegram uploads at 16MB. Thanks @vincentkoc.
  • -
  • Skills/nano-banana-pro resolution override: respect explicit --resolution values during image editing and only auto-detect output size from input images when the flag is omitted. (#36880) Thanks @shuofengzhang and @vincentkoc.
  • -
  • Skills/openai-image-gen CLI validation: validate --background and --style inputs early, normalize supported values, and warn when those flags are ignored for incompatible models. (#36762) Thanks @shuofengzhang and @vincentkoc.
  • -
  • Skills/openai-image-gen output formats: validate --output-format values early, normalize aliases like jpg -> jpeg, and warn when the flag is ignored for incompatible models. (#36648) Thanks @shuofengzhang and @vincentkoc.
  • -
  • ACP/skill env isolation: strip skill-injected API keys from ACP harness child-process environments so tools like Codex CLI keep their own auth flow instead of inheriting billed provider keys from active skills. (#36316) Thanks @taw0002 and @vincentkoc.
  • -
  • WhatsApp media upload caps: make outbound media sends and auto-replies honor channels.whatsapp.mediaMaxMb with per-account overrides so inbound and outbound limits use the same channel config. Thanks @vincentkoc.
  • -
  • Windows/Plugin install: when OpenClaw runs on Windows via Bun and npm-cli.js is not colocated with the runtime binary, fall back to npm.cmd/npx.cmd through the existing cmd.exe wrapper so openclaw plugins install no longer fails with spawn EINVAL. (#38056) Thanks @0xlin2023.
  • -
  • Telegram/send retry classification: retry grammY Network request ... failed after N attempts envelopes in send flows without reclassifying plain Network request ... failed! wrappers as transient, restoring the intended retry path while keeping broad send-context message matching tight. (#38056) Thanks @0xlin2023.
  • -
  • Gateway/probes: keep /health, /healthz, /ready, and /readyz reachable when the Control UI is mounted at /, preserve plugin-owned route precedence on those paths, and make /ready and /readyz report channel-backed readiness with startup grace plus 503 on disconnected managed channels, while /health and /healthz stay shallow liveness probes. (#18446) Thanks @vibecodooor, @mahsumaktas, and @vincentkoc.
  • -
  • Feishu/media downloads: drop invalid timeout fields from SDK method calls now that client-level httpTimeoutMs applies to requests. (#38267) Thanks @ant1eicher and @thewilloftheshadow.
  • -
  • PI embedded runner/Feishu docs: propagate sender identity into embedded attempts so Feishu doc auto-grant restores requester access for embedded-runner executions. (#32915) thanks @cszhouwei.
  • -
  • Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so openclaw agent --json no longer crashes when provider payloads omit totalTokens or related usage fields. (#34977) thanks @sp-hk2ldn.
  • -
  • Venice/default model refresh: switch the built-in Venice default to kimi-k2-5, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc.
  • -
  • Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect 429/Retry-After. Thanks @vincentkoc.
  • -
  • Google Chat/multi-account webhook auth fallback: when channels.googlechat.accounts.default carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369.
  • -
  • Models/tool probing: raise the tool-capability probe budget from 32 to 256 tokens so reasoning models that spend tokens on thinking before returning a required tool call are less likely to be misclassified as not supporting tools. (#7521) Thanks @jakobdylanc.
  • -
  • Gateway/transient network classification: treat wrapped ...: fetch failed transport messages as transient while avoiding broad matches like Web fetch failed (404): ..., preventing Discord reconnect wrappers from crashing the gateway without suppressing non-network tool failures. (#38530) Thanks @xinhuagu.
  • -
  • ACP/console silent reply suppression: filter ACP NO_REPLY lead fragments and silent-only finals before openclaw agent logging/delivery so console-backed ACP sessions no longer leak NO/NO_REPLY placeholders. (#38436) Thanks @ql-wade.
  • -
  • Feishu/reply delivery reliability: disable block streaming in Feishu reply options so plain-text auto-render replies are no longer silently dropped before final delivery. (#38258) Thanks @xinhuagu.
  • -
  • Agents/reply MEDIA delivery: normalize local assistant MEDIA: paths before block/final delivery, keep media dedupe aligned with message-tool sends, and contain malformed media normalization failures so generated files send reliably instead of falling back to empty responses. (#38572) Thanks @obviyus.
  • -
  • Sessions/bootstrap cache rollover invalidation: clear cached workspace bootstrap snapshots whenever an existing sessionKey rolls to a new sessionId across auto-reply, command, and isolated cron session resolvers, so AGENTS.md/MEMORY.md/USER.md updates are reloaded after daily, idle, or forced session resets instead of staying stale until gateway restart. (#38494) Thanks @LivingInDrm.
  • -
  • Gateway/Telegram polling health monitor: skip stale-socket restarts for Telegram long-polling channels and thread channel identity through shared health evaluation so polling connections are not restarted on the WebSocket stale-socket heuristic. (#38395) Thanks @ql-wade and @Takhoffman.
  • -
  • Daemon/systemd fresh-install probe: check for OpenClaw's managed user unit before running systemctl --user is-enabled, so first-time Linux installs no longer fail on generic missing-unit probe errors. (#38819) Thanks @adaHubble.
  • -
  • Gateway/container lifecycle: allow openclaw gateway stop to SIGTERM unmanaged gateway listeners and openclaw gateway restart to SIGUSR1 a single unmanaged listener when no service manager is installed, so container and supervisor-based deployments are no longer blocked by service disabled no-op responses. Fixes #36137. Thanks @vincentkoc.
  • -
  • Gateway/Windows restart supervision: relaunch task-managed gateways through Scheduled Task with quoted helper-script command paths, distinguish restart-capable supervisors per platform, and stop orphaned Windows gateway children during self-restart. (#38825) Thanks @obviyus.
  • -
  • Telegram/native topic command routing: resolve forum-topic native commands through the same conversation route as inbound messages so topic agentId overrides and bound topic sessions target the active session instead of the default topic-parent session. (#38871) Thanks @obviyus.
  • -
  • Markdown/assistant image hardening: flatten remote markdown images to plain text across the Control UI, exported HTML, and shared Swift chat while keeping inline data:image/... markdown renderable, so model output no longer triggers automatic remote image fetches. (#38895) Thanks @obviyus.
  • -
  • Config/compaction safeguard settings: regression-test agents.defaults.compaction.recentTurnsPreserve through loadConfig() and cover the new help metadata entry so the exposed preserve knob stays wired through schema validation and config UX. (#25557) thanks @rodrigouroz.
  • -
  • iOS/Quick Setup presentation: skip automatic Quick Setup when a gateway is already configured (active connect config, last-known connection, preferred gateway, or manual host), so reconnecting installs no longer get prompted to connect again. (#38964) Thanks @ngutman.
  • -
  • CLI/Docs memory help accuracy: clarify openclaw memory status --deep behavior and align memory command examples/docs with the current search options. (#31803) Thanks @JasonOA888 and @Avi974.
  • -
  • Auto-reply/allowlist store account scoping: keep /allowlist ... --store writes scoped to the selected account and clear legacy unscoped entries when removing default-account store access, preventing cross-account default allowlist bleed-through from legacy pairing-store reads. Thanks @tdjackey for reporting and @vincentkoc for the fix.
  • -
  • Security/Nostr: harden profile mutation/import loopback guards by failing closed on non-loopback forwarded client headers (x-forwarded-for / x-real-ip) and rejecting sec-fetch-site: cross-site; adds regression coverage for proxy-forwarded and browser cross-site mutation attempts.
  • -
  • CLI/bootstrap Node version hint maintenance: replace hardcoded nvm 22 instructions in openclaw.mjs with MIN_NODE_MAJOR interpolation so future minimum-Node bumps keep startup guidance in sync automatically. (#39056) Thanks @onstash.
  • -
  • Discord/native slash command auth: honor commands.allowFrom.discord (and commands.allowFrom["*"]) in guild slash-command pre-dispatch authorization so allowlisted senders are no longer incorrectly rejected as unauthorized. (#38794) Thanks @jskoiz and @thewilloftheshadow.
  • -
  • Outbound/message target normalization: ignore empty legacy to/channelId fields when explicit target is provided so valid target-based sends no longer fail legacy-param validation; includes regression coverage. (#38944) Thanks @Narcooo.
  • -
  • Models/auth token prompts: guard cancelled manual token prompts so Symbol(clack:cancel) values cannot be persisted into auth profiles; adds regression coverage for cancelled models auth paste-token. (#38951) Thanks @MumuTW.
  • -
  • Gateway/loopback announce URLs: treat http:// and https:// aliases with the same loopback/private-network policy as websocket URLs so loopback cron announce delivery no longer fails secure URL validation. (#39064) Thanks @Narcooo.
  • -
  • Models/default provider fallback: when the hardcoded default provider is removed from models.providers, resolve defaults from configured providers instead of reporting stale removed-provider defaults in status output. (#38947) Thanks @davidemanuelDEV.
  • -
  • Agents/cache-trace stability: guard stable stringify against circular references in trace payloads so near-limit payloads no longer crash with Maximum call stack size exceeded; adds regression coverage. (#38935) Thanks @MumuTW.
  • -
  • Extensions/diffs CI stability: add headers to the localReq test helper in extensions/diffs/index.test.ts so forwarding-hint checks no longer crash with req.headers undefined. (supersedes #39063) Thanks @Shennng.
  • -
  • Agents/compaction thresholding: apply agents.defaults.contextTokens cap to the model passed into embedded run and /compact session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW.
  • -
  • Models/merge mode provider precedence: when models.mode: "merge" is active and config explicitly sets a provider baseUrl, keep config as source of truth instead of preserving stale runtime models.json baseUrl values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle.
  • -
  • UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling tool-events capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent.
  • -
  • Models/provider apiKey persistence hardening: when a provider apiKey value equals a known provider env var value, persist the canonical env var name into models.json instead of resolved plaintext secrets. (#38889) Thanks @gambletan.
  • -
  • Discord/model picker persistence check: add a short post-dispatch settle delay before reading back session model state so picker confirmations stop reporting false mismatch warnings after successful model switches. (#39105) Thanks @akropp.
  • -
  • Agents/OpenAI WS compat store flag: omit store from response.create payloads when model compat sets supportsStore: false, preventing strict OpenAI-compatible providers from rejecting websocket requests with unknown-field errors. (#39113) Thanks @scoootscooob.
  • -
  • Config/validation log sanitization: sanitize config-validation issue paths/messages before logging so control characters and ANSI escape sequences cannot inject misleading terminal output from crafted config content. (#39116) Thanks @powermaster888.
  • -
  • Agents/compaction counter accuracy: count successful overflow-triggered auto-compactions (willRetry=true) in the compaction counter while still excluding aborted/no-result events, so /status reflects actual safeguard compaction activity. (#39123) Thanks @MumuTW.
  • -
  • Gateway/chat delta ordering: flush buffered assistant deltas before emitting tool start events so pre-tool text is delivered to Control UI before tool cards, avoiding transient text/tool ordering artifacts in streaming. (#39128) Thanks @0xtangping.
  • -
  • Voice-call plugin schema parity: add missing manifest configSchema fields (webhookSecurity, streaming.preStartTimeoutMs|maxPendingConnections|maxPendingConnectionsPerIp|maxConnections, staleCallReaperSeconds) so gateway AJV validation accepts already-supported runtime config instead of failing with additionalProperties errors. (#38892) Thanks @giumex.
  • -
  • Agents/OpenAI WS reconnect retry accounting: avoid double retry scheduling when reconnect failures emit both error and close, so retry budgets track actual reconnect attempts instead of exhausting early. (#39133) Thanks @scoootscooob.
  • -
  • Daemon/Windows schtasks runtime detection: use locale-invariant Last Run Result running codes (0x41301/267009) as the primary running signal so openclaw node status no longer misreports active tasks as stopped on non-English Windows locales. (#39076) Thanks @ademczuk.
  • -
  • Usage/token count formatting: round near-million token counts to millions (1.0m) instead of 1000k, with explicit boundary coverage for 999_499 and 999_500. (#39129) Thanks @CurryMessi.
  • -
  • Gateway/session bootstrap cache invalidation ordering: clear bootstrap snapshots only after active embedded-run shutdown wait completes, preventing dying runs from repopulating stale cache between /new/sessions.reset turns. (#38873) Thanks @MumuTW.
  • -
  • Browser/dispatcher error clarity: preserve dispatcher-side failure context in browser fetch errors while still appending operator guidance and explicit no-retry model hints, preventing misleading "Can't reach service" wrapping and avoiding LLM retry loops. (#39090) Thanks @NewdlDewdl.
  • -
  • Telegram/polling offset safety: confirm persisted offsets before polling startup while validating stored lastUpdateId values as non-negative safe integers (with overflow guards) so malformed offset state cannot cause update skipping/dropping. (#39111) Thanks @MumuTW.
  • -
  • Telegram/status SecretRef read-only resolution: resolve env-backed bot-token SecretRefs in config-only/status inspection while respecting provider source/defaults and env allowlists, so status no longer crashes or reports false-ready tokens for disallowed providers. (#39130) Thanks @neocody.
  • -
  • Agents/OpenAI WS max-token zero forwarding: treat maxTokens: 0 as an explicit value in websocket response.create payloads (instead of dropping it as falsy), with regression coverage for zero-token forwarding. (#39148) Thanks @scoootscooob.
  • -
  • Podman/.env gateway bind precedence: evaluate OPENCLAW_GATEWAY_BIND after sourcing .env in run-openclaw-podman.sh so env-file overrides are honored. (#38785) Thanks @majinyu666.
  • -
  • Models/default alias refresh: bump gpt to openai/gpt-5.4 and Gemini defaults to gemini-3.1 preview aliases (including normalization/default wiring) to track current model IDs. (#38638) Thanks @ademczuk.
  • -
  • Config/env substitution degraded mode: convert missing ${VAR} resolution in config reads from hard-fail to warning-backed degraded behavior, while preventing unresolved placeholders from being accepted as gateway credentials. (#39050) Thanks @akz142857.
  • -
  • Discord inbound listener non-blocking dispatch: make MESSAGE_CREATE listener handoff asynchronous (no per-listener queue blocking), so long runs no longer stall unrelated incoming events. (#39154) Thanks @yaseenkadlemakki.
  • -
  • Daemon/Windows PATH freeze fix: stop persisting install-time PATH snapshots into Scheduled Task scripts so runtime tool lookup follows current host PATH updates; also refresh local TUI history on silent local finals. (#39139) Thanks @Narcooo.
  • -
  • Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie.
  • -
  • Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses /talkvoice natively on Discord while keeping text /voice.
  • -
  • Daemon/Windows schtasks status normalization: derive runtime state from locale-neutral numeric Last Run Result codes only (without language string matching) and surface unknown when numeric result data is unavailable, preventing locale-specific misclassification drift. (#39153) Thanks @scoootscooob.
  • -
  • Telegram/polling conflict recovery: reset the polling webhookCleared latch on getUpdates 409 conflicts so webhook cleanup re-runs on restart cycles and polling avoids infinite conflict loops. (#39205) Thanks @amittell.
  • -
  • Heartbeat/requests-in-flight scheduling: stop advancing nextDueMs and avoid immediate scheduleNext() timer overrides on requests-in-flight skips, so wake-layer retry cooldowns are honored and heartbeat cadence no longer drifts under sustained contention. (#39182) Thanks @MumuTW.
  • -
  • Memory/SQLite contention resilience: re-apply PRAGMA busy_timeout on every sync-store and QMD connection open so process restarts/reopens no longer revert to immediate SQLITE_BUSY failures under lock contention. (#39183) Thanks @MumuTW.
  • -
  • Gateway/webchat route safety: block webchat/control-ui clients from inheriting stored external delivery routes on channel-scoped sessions (while preserving route inheritance for UI/TUI clients), preventing cross-channel leakage from scoped chats. (#39175) Thanks @widingmarcus-cyber.
  • -
  • Telegram error-surface resilience: return a user-visible fallback reply when dispatch/debounce processing fails instead of going silent, while preserving draft-stream cleanup and best-effort thread-scoped fallback delivery. (#39209) Thanks @riftzen-bit.
  • -
  • Gateway/password auth startup diagnostics: detect unresolved provider-reference objects in gateway.auth.password and fail with a specific bootstrap-secrets error message instead of generic misconfiguration output. (#39230) Thanks @ademczuk.
  • -
  • Agents/OpenAI-responses compatibility: strip unsupported store payload fields when supportsStore=false (including OpenAI-compatible non-OpenAI providers) while preserving server-compaction payload behavior. (#39219) Thanks @ademczuk.
  • -
  • Agents/model fallback visibility: warn when configured model IDs cannot be resolved and fallback is applied, with log-safe sanitization of model text to prevent control-sequence injection in warning output. (#39215) Thanks @ademczuk.
  • -
  • Outbound delivery replay safety: use two-phase delivery ACK markers (.json -> .delivered -> unlink) and startup marker cleanup so crash windows between send and cleanup do not replay already-delivered messages. (#38668) Thanks @Gundam98.
  • -
  • Nodes/system.run approval binding: carry prepared approval plans through gateway forwarding and bind interpreter-style script operands across approval to execution, so post-approval script rewrites are denied while unchanged approved script runs keep working. Thanks @tdjackey for reporting.
  • -
  • Nodes/system.run PowerShell wrapper parsing: treat pwsh/powershell -EncodedCommand forms as shell-wrapper payloads so allowlist mode still requires approval instead of falling back to plain argv analysis. Thanks @tdjackey for reporting.
  • -
  • Control UI/auth error reporting: map generic browser Fetch failed websocket close errors back to actionable gateway auth messages (gateway token mismatch, authentication failed, retry later) so dashboard disconnects stop hiding credential problems. Landed from contributor PR #28608 by @KimGLee. Thanks @KimGLee.
  • -
  • Media/mime unknown-kind handling: return undefined (not "unknown") for missing/unrecognized MIME kinds and use document-size fallback caps for unknown remote media, preventing phantom Signal events from being treated as real messages. (#39199) Thanks @nicolasgrasset.
  • -
  • Nodes/system.run allow-always persistence: honor shell comment semantics during allowlist analysis so #-tailed payloads that never execute are not persisted as trusted follow-up commands. Thanks @tdjackey for reporting.
  • -
  • Signal/inbound attachment fan-in: forward all successfully fetched inbound attachments through MediaPaths/MediaUrls/MediaTypes (instead of only the first), and improve multi-attachment placeholder summaries in mention-gated pending history. (#39212) Thanks @joeykrug.
  • -
  • Nodes/system.run dispatch-wrapper boundary: keep shell-wrapper approval classification active at the depth boundary so env wrapper stacks cannot reach /bin/sh -c execution without the expected approval gate. Thanks @tdjackey for reporting.
  • -
  • Docker/token persistence on reconfigure: reuse the existing .env gateway token during docker-setup.sh reruns and align compose token env defaults, so Docker installs stop silently rotating tokens and breaking existing dashboard sessions. Landed from contributor PR #33097 by @chengzhichao-xydt. Thanks @chengzhichao-xydt.
  • -
  • Agents/strict OpenAI turn ordering: apply assistant-first transcript bootstrap sanitization to strict OpenAI-compatible providers (for example vLLM/Gemma via openai-completions) without adding Google-specific session markers, preventing assistant-first history rejections. (#39252) Thanks @scoootscooob.
  • -
  • Discord/exec approvals gateway auth: pass resolved shared gateway credentials into the Discord exec-approvals gateway client so token-auth installs stop failing approvals with gateway token mismatch. Related to #38179. Thanks @0riginal-claw for the adjacent PR #35147 investigation.
  • -
  • Subagents/workspace inheritance: propagate parent workspace directory to spawned subagent runs so child sessions reliably inherit workspace-scoped instructions (AGENTS.md, SOUL.md, etc.) without exposing workspace override through tool-call arguments. (#39247) Thanks @jasonQin6.
  • -
  • Exec approvals/gateway-node policy: honor explicit ask=off from exec-approvals.json even when runtime defaults are stricter, so trusted full/off setups stop re-prompting on gateway and node exec paths. Landed from contributor PR #26789 by @pandego. Thanks @pandego.
  • -
  • Exec approvals/config fallback: inherit ask from exec-approvals.json when tools.exec.ask is unset, so local full/off defaults no longer fall back to on-miss for exec tool and nodes run. Landed from contributor PR #29187 by @Bartok9. Thanks @Bartok9.
  • -
  • Exec approvals/allow-always shell scripts: persist and match script paths for wrapper invocations like bash scripts/foo.sh while still blocking -c/-s wrapper bypasses. Landed from contributor PR #35137 by @yuweuii. Thanks @yuweuii.
  • -
  • Queue/followup dedupe across drain restarts: dedupe queued redelivery message_id values after queue recreation so busy-session followups no longer duplicate on replayed inbound events. Landed from contributor PR #33168 by @rylena. Thanks @rylena.
  • -
  • Telegram/preview-final edit idempotence: treat message is not modified errors during preview finalization as delivered so partial-stream final replies do not fall back to duplicate sends. Landed from contributor PR #34983 by @HOYALIM. Thanks @HOYALIM.
  • -
  • Telegram/DM streaming transport parity: use message preview transport for all DM streaming lanes so final delivery can edit the active preview instead of sending duplicate finals. Landed from contributor PR #38906 by @gambletan. Thanks @gambletan.
  • -
  • Telegram/DM draft streaming restoration: restore native sendMessageDraft preview transport for DM answer streaming while keeping reasoning on message transport, with regression coverage to keep draft finalization from sending duplicate finals. (#39398) Thanks @obviyus.
  • -
  • Telegram/send retry safety: retry non-idempotent send paths only for pre-connect failures and make custom retry predicates strict, preventing ambiguous reconnect retries from sending duplicate messages. Landed from contributor PR #34238 by @hal-crackbot. Thanks @hal-crackbot.
  • -
  • ACP/run spawn delivery bootstrap: stop reusing requester inline delivery targets for one-shot mode: "run" ACP spawns, so fresh run-mode workers bootstrap in isolation instead of inheriting thread-bound session delivery behavior. (#39014) Thanks @lidamao633.
  • -
  • Discord/DM session-key normalization: rewrite legacy discord:dm:* and phantom direct-message discord:channel: session keys to discord:direct:* when the sender matches, so multi-agent Discord DMs stop falling into empty channel-shaped sessions and resume replying correctly.
  • -
  • Discord/native slash session fallback: treat empty configured bound-session keys as missing so /status and other native commands fall back to the routed slash session and routed channel session instead of blanking Discord session keys in normal channel bindings.
  • -
  • Agents/tool-call dispatch normalization: normalize provider-prefixed tool names before dispatch across toolCall, toolUse, and functionCall blocks, while preserving multi-segment tool suffixes when stripping provider wrappers so malformed-but-recoverable tool names no longer fail with Tool not found. (#39328) Thanks @vincentkoc.
  • -
  • Agents/parallel tool-call compatibility: honor parallel_tool_calls / parallelToolCalls extra params only for openai-completions and openai-responses payloads, preserve higher-precedence alias overrides across config and runtime layers, and ignore invalid non-boolean values so single-tool-call providers like NVIDIA-hosted Kimi stop failing on forced parallel tool-call payloads. (#37048) Thanks @vincentkoc.
  • -
  • Config/invalid-load fail-closed: stop converting INVALID_CONFIG into an empty runtime config, keep valid settings available only through explicit best-effort diagnostic reads, and route read-only CLI diagnostics through that path so unknown keys no longer silently drop security-sensitive config. (#28140) Thanks @bobsahur-robot and @vincentkoc.
  • -
  • Agents/codex-cli sandbox defaults: switch the built-in Codex backend from read-only to workspace-write so spawned coding runs can edit files out of the box. Landed from contributor PR #39336 by @0xtangping. Thanks @0xtangping.
  • -
  • Gateway/health-monitor restart reason labeling: report disconnected instead of stuck for clean channel disconnect restarts, so operator logs distinguish socket drops from genuinely stuck channels. (#36436) Thanks @Sid-Qin.
  • -
  • Control UI/agents-page overrides: auto-create minimal per-agent config entries when editing inherited agents, so model/tool/skill changes enable Save and inherited model fallbacks can be cleared by writing a primary-only override. Landed from contributor PR #39326 by @dunamismax. Thanks @dunamismax.
  • -
  • Gateway/Telegram webhook-mode recovery: add webhookCertPath to re-upload self-signed certificates during webhook registration and skip stale-socket detection for webhook-mode channels, so Telegram webhook setups survive health-monitor restarts. Landed from contributor PR #39313 by @fellanH. Thanks @fellanH.
  • -
  • Discord/config schema parity: add channels.discord.agentComponents to the strict Zod config schema so valid agentComponents.enabled settings (root and account-scoped) no longer fail with unrecognized-key validation errors. Landed from contributor PR #39378 by @gambletan. Thanks @gambletan and @thewilloftheshadow.
  • -
  • ACPX/MCP session bootstrap: inject configured MCP servers into ACP session/new and session/load for acpx-backed sessions, restoring Canva and other external MCP tools. Landed from contributor PR #39337. Thanks @goodspeed-apps.
  • -
  • Control UI/Telegram sender labels: preserve inbound sender labels in sanitized chat history so dashboard user-message groups split correctly and show real group-member names instead of You. (#39414) Thanks @obviyus.
  • -
-

View full changelog

-]]>
- -
- - 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/README.md b/apps/android/README.md index 0a92e4c8ec5..9c6baf807c9 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -30,8 +30,12 @@ cd apps/android ./gradlew :app:assembleDebug ./gradlew :app:installDebug ./gradlew :app:testDebugUnitTest +cd ../.. +bun run android:bundle:release ``` +`bun run android:bundle:release` auto-bumps Android `versionName`/`versionCode` in `apps/android/app/build.gradle.kts`, then builds a signed release `.aab`. + ## Kotlin Lint + Format ```bash diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 32306780c72..46afccbc3bf 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -1,5 +1,7 @@ import com.android.build.api.variant.impl.VariantOutputImpl +val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider" + val androidStoreFile = providers.gradleProperty("OPENCLAW_ANDROID_STORE_FILE").orNull?.takeIf { it.isNotBlank() } val androidStorePassword = providers.gradleProperty("OPENCLAW_ANDROID_STORE_PASSWORD").orNull?.takeIf { it.isNotBlank() } val androidKeyAlias = providers.gradleProperty("OPENCLAW_ANDROID_KEY_ALIAS").orNull?.takeIf { it.isNotBlank() } @@ -63,8 +65,8 @@ android { applicationId = "ai.openclaw.app" minSdk = 31 targetSdk = 36 - versionCode = 202603110 - versionName = "2026.3.11" + versionCode = 2026031400 + versionName = "2026.3.14" ndk { // Support all major ABIs — native libs are tiny (~47 KB per ABI) abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64") @@ -78,6 +80,9 @@ android { } isMinifyEnabled = true isShrinkResources = true + ndk { + debugSymbolLevel = "SYMBOL_TABLE" + } proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } debug { @@ -104,6 +109,10 @@ android { "/META-INF/LICENSE*.txt", "DebugProbesKt.bin", "kotlin-tooling-metadata.json", + "org/bouncycastle/pqc/crypto/picnic/lowmcL1.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL3.bin.properties", + "org/bouncycastle/pqc/crypto/picnic/lowmcL5.bin.properties", + "org/bouncycastle/x509/CertPathReviewerMessages*.properties", ) } } @@ -168,7 +177,6 @@ dependencies { // material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used. // R8 will tree-shake unused icons when minify is enabled on release builds. implementation("androidx.compose.material:material-icons-extended") - implementation("androidx.navigation:navigation-compose:2.9.7") debugImplementation("androidx.compose.ui:ui-tooling") @@ -193,8 +201,7 @@ dependencies { implementation("androidx.camera:camera-camera2:1.5.2") 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") @@ -211,3 +218,45 @@ dependencies { tasks.withType().configureEach { useJUnitPlatform() } + +val stripReleaseDnsjavaServiceDescriptor = + tasks.register("stripReleaseDnsjavaServiceDescriptor") { + val mergedJar = + layout.buildDirectory.file( + "intermediates/merged_java_res/release/mergeReleaseJavaResource/base.jar", + ) + + inputs.file(mergedJar) + outputs.file(mergedJar) + + doLast { + val jarFile = mergedJar.get().asFile + if (!jarFile.exists()) { + return@doLast + } + + val unpackDir = temporaryDir.resolve("merged-java-res") + delete(unpackDir) + copy { + from(zipTree(jarFile)) + into(unpackDir) + exclude(dnsjavaInetAddressResolverService) + } + delete(jarFile) + ant.invokeMethod( + "zip", + mapOf( + "destfile" to jarFile.absolutePath, + "basedir" to unpackDir.absolutePath, + ), + ) + } + } + +tasks.matching { it.name == "stripReleaseDnsjavaServiceDescriptor" }.configureEach { + dependsOn("mergeReleaseJavaResource") +} + +tasks.matching { it.name == "minifyReleaseWithR8" }.configureEach { + dependsOn(stripReleaseDnsjavaServiceDescriptor) +} diff --git a/apps/android/app/proguard-rules.pro b/apps/android/app/proguard-rules.pro index 78e4a363919..7c04b96833a 100644 --- a/apps/android/app/proguard-rules.pro +++ b/apps/android/app/proguard-rules.pro @@ -1,26 +1,6 @@ -# ── App classes ─────────────────────────────────────────────────── --keep class ai.openclaw.app.** { *; } - -# ── Bouncy Castle ───────────────────────────────────────────────── --keep class org.bouncycastle.** { *; } -dontwarn org.bouncycastle.** - -# ── CameraX ─────────────────────────────────────────────────────── --keep class androidx.camera.** { *; } - -# ── kotlinx.serialization ──────────────────────────────────────── --keep class kotlinx.serialization.** { *; } --keepclassmembers class * { - @kotlinx.serialization.Serializable *; -} --keepattributes *Annotation*, InnerClasses - -# ── OkHttp ──────────────────────────────────────────────────────── -dontwarn okhttp3.** -dontwarn okio.** --keep class okhttp3.internal.platform.** { *; } - -# ── Misc suppressions ──────────────────────────────────────────── -dontwarn com.sun.jna.** -dontwarn javax.naming.** -dontwarn lombok.Generated diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index a1b6ba3d353..80f42e02843 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -116,6 +116,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.setGatewayToken(value) } + fun setGatewayBootstrapToken(value: String) { + runtime.setGatewayBootstrapToken(value) + } + fun setGatewayPassword(value: String) { runtime.setGatewayPassword(value) } @@ -172,6 +176,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) { runtime.requestCanvasRehydrate(source = source, force = true) } + fun refreshHomeCanvasOverviewIfConnected() { + runtime.refreshHomeCanvasOverviewIfConnected() + } + fun loadChat(sessionKey: String) { runtime.loadChat(sessionKey) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index c4e5f6a5b1d..dcf1e3bee89 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -33,6 +33,8 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -210,7 +212,8 @@ class NodeRuntime(context: Context) { private val _isForeground = MutableStateFlow(true) val isForeground: StateFlow = _isForeground.asStateFlow() - private var lastAutoA2uiUrl: String? = null + private var gatewayDefaultAgentId: String? = null + private var gatewayAgents: List = emptyList() private var didAutoRequestCanvasRehydrate = false private val canvasRehydrateSeq = AtomicLong(0) private var operatorConnected = false @@ -232,7 +235,7 @@ class NodeRuntime(context: Context) { updateStatus() micCapture.onGatewayConnectionChanged(true) scope.launch { - refreshBrandingFromGateway() + refreshHomeCanvasOverviewIfConnected() if (voiceReplySpeakerLazy.isInitialized()) { voiceReplySpeaker.refreshConfig() } @@ -270,7 +273,7 @@ class NodeRuntime(context: Context) { _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = null updateStatus() - maybeNavigateToA2uiOnConnect() + showLocalCanvasOnConnect() }, onDisconnected = { message -> _nodeConnected.value = false @@ -396,6 +399,7 @@ class NodeRuntime(context: Context) { _mainSessionKey.value = trimmed talkMode.setMainSessionKey(trimmed) chat.applyMainSessionKey(trimmed) + updateHomeCanvasState() } private fun updateStatus() { @@ -415,6 +419,7 @@ class NodeRuntime(context: Context) { operator.isNotBlank() && operator != "Offline" -> operator else -> node } + updateHomeCanvasState() } private fun resolveMainSessionKey(): String { @@ -422,23 +427,31 @@ class NodeRuntime(context: Context) { return if (trimmed.isEmpty()) "main" else trimmed } - private fun maybeNavigateToA2uiOnConnect() { - val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return - val current = canvas.currentUrl()?.trim().orEmpty() - if (current.isEmpty() || current == lastAutoA2uiUrl) { - lastAutoA2uiUrl = a2uiUrl - canvas.navigate(a2uiUrl) - } - } - - private fun showLocalCanvasOnDisconnect() { - lastAutoA2uiUrl = null + private fun showLocalCanvasOnConnect() { _canvasA2uiHydrated.value = false _canvasRehydratePending.value = false _canvasRehydrateErrorText.value = null canvas.navigate("") } + private fun showLocalCanvasOnDisconnect() { + _canvasA2uiHydrated.value = false + _canvasRehydratePending.value = false + _canvasRehydrateErrorText.value = null + canvas.navigate("") + } + + fun refreshHomeCanvasOverviewIfConnected() { + if (!operatorConnected) { + updateHomeCanvasState() + return + } + scope.launch { + refreshBrandingFromGateway() + refreshAgentsFromGateway() + } + } + fun requestCanvasRehydrate(source: String = "manual", force: Boolean = true) { scope.launch { if (!_nodeConnected.value) { @@ -503,6 +516,7 @@ class NodeRuntime(context: Context) { val gatewayToken: StateFlow = prefs.gatewayToken val onboardingCompleted: StateFlow = prefs.onboardingCompleted fun setGatewayToken(value: String) = prefs.setGatewayToken(value) + fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value) fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value) fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value) val lastDiscoveredStableId: StateFlow = prefs.lastDiscoveredStableId @@ -601,6 +615,8 @@ class NodeRuntime(context: Context) { canvas.setDebugStatus(status, server ?: remote) } } + + updateHomeCanvasState() } fun setForeground(value: Boolean) { @@ -698,10 +714,25 @@ class NodeRuntime(context: Context) { operatorStatusText = "Connecting…" updateStatus() val token = prefs.loadGatewayToken() + val bootstrapToken = prefs.loadGatewayBootstrapToken() val password = prefs.loadGatewayPassword() val tls = connectionManager.resolveTlsParams(endpoint) - operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) + operatorSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildOperatorConnectOptions(), + tls, + ) + nodeSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildNodeConnectOptions(), + tls, + ) operatorSession.reconnect() nodeSession.reconnect() } @@ -726,9 +757,24 @@ class NodeRuntime(context: Context) { nodeStatusText = "Connecting…" updateStatus() val token = prefs.loadGatewayToken() + val bootstrapToken = prefs.loadGatewayBootstrapToken() val password = prefs.loadGatewayPassword() - operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls) - nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls) + operatorSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildOperatorConnectOptions(), + tls, + ) + nodeSession.connect( + endpoint, + token, + bootstrapToken, + password, + connectionManager.buildNodeConnectOptions(), + tls, + ) } fun acceptGatewayTrustPrompt() { @@ -897,11 +943,177 @@ class NodeRuntime(context: Context) { val parsed = parseHexColorArgb(raw) _seamColorArgb.value = parsed ?: DEFAULT_SEAM_COLOR_ARGB + updateHomeCanvasState() } catch (_: Throwable) { // ignore } } + private suspend fun refreshAgentsFromGateway() { + if (!operatorConnected) return + try { + val res = operatorSession.request("agents.list", "{}") + val root = json.parseToJsonElement(res).asObjectOrNull() ?: return + val defaultAgentId = root["defaultId"].asStringOrNull()?.trim().orEmpty() + val mainKey = normalizeMainKey(root["mainKey"].asStringOrNull()) + val agents = + (root["agents"] as? JsonArray)?.mapNotNull { item -> + val obj = item.asObjectOrNull() ?: return@mapNotNull null + val id = obj["id"].asStringOrNull()?.trim().orEmpty() + if (id.isEmpty()) return@mapNotNull null + val name = obj["name"].asStringOrNull()?.trim() + val emoji = obj["identity"].asObjectOrNull()?.get("emoji").asStringOrNull()?.trim() + GatewayAgentSummary( + id = id, + name = name?.takeIf { it.isNotEmpty() }, + emoji = emoji?.takeIf { it.isNotEmpty() }, + ) + } ?: emptyList() + + gatewayDefaultAgentId = defaultAgentId.ifEmpty { null } + gatewayAgents = agents + applyMainSessionKey(mainKey) + updateHomeCanvasState() + } catch (_: Throwable) { + // ignore + } + } + + private fun updateHomeCanvasState() { + val payload = + try { + json.encodeToString(makeHomeCanvasPayload()) + } catch (_: Throwable) { + null + } + canvas.updateHomeCanvasState(payload) + } + + private fun makeHomeCanvasPayload(): HomeCanvasPayload { + val state = resolveHomeCanvasGatewayState() + val gatewayName = normalized(_serverName.value) + val gatewayAddress = normalized(_remoteAddress.value) + val gatewayLabel = gatewayName ?: gatewayAddress ?: "Gateway" + val activeAgentId = resolveActiveAgentId() + val agents = homeCanvasAgents(activeAgentId) + + return when (state) { + HomeCanvasGatewayState.Connected -> + HomeCanvasPayload( + gatewayState = "connected", + eyebrow = "Connected to $gatewayLabel", + title = "Your agents are ready", + subtitle = + "This phone stays dormant until the gateway needs it, then wakes, syncs, and goes back to sleep.", + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = agents.firstOrNull { it.isActive }?.badge ?: "OC", + activeAgentCaption = "Selected on this phone", + agentCount = agents.size, + agents = agents.take(6), + footer = "The overview refreshes on reconnect and when this screen opens.", + ) + HomeCanvasGatewayState.Connecting -> + HomeCanvasPayload( + gatewayState = "connecting", + eyebrow = "Reconnecting", + title = "OpenClaw is syncing back up", + subtitle = + "The gateway session is coming back online. Agent shortcuts should settle automatically in a moment.", + gatewayLabel = gatewayLabel, + activeAgentName = resolveActiveAgentName(activeAgentId), + activeAgentBadge = "OC", + activeAgentCaption = "Gateway session in progress", + agentCount = agents.size, + agents = agents.take(4), + footer = "If the gateway is reachable, reconnect should complete without intervention.", + ) + HomeCanvasGatewayState.Error, HomeCanvasGatewayState.Offline -> + HomeCanvasPayload( + gatewayState = if (state == HomeCanvasGatewayState.Error) "error" else "offline", + eyebrow = "Welcome to OpenClaw", + title = "Your phone stays quiet until it is needed", + subtitle = + "Pair this device to your gateway to wake it only for real work, keep a live agent overview handy, and avoid battery-draining background loops.", + gatewayLabel = gatewayLabel, + activeAgentName = "Main", + activeAgentBadge = "OC", + activeAgentCaption = "Connect to load your agents", + agentCount = agents.size, + agents = agents.take(4), + footer = "When connected, the gateway can wake the phone with a silent push instead of holding an always-on session.", + ) + } + } + + private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState { + val lower = _statusText.value.trim().lowercase() + return when { + _isConnected.value -> HomeCanvasGatewayState.Connected + lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting + lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error + else -> HomeCanvasGatewayState.Offline + } + } + + private fun resolveActiveAgentId(): String { + val mainKey = _mainSessionKey.value.trim() + if (mainKey.startsWith("agent:")) { + val agentId = mainKey.removePrefix("agent:").substringBefore(':').trim() + if (agentId.isNotEmpty()) return agentId + } + return gatewayDefaultAgentId?.trim().orEmpty() + } + + private fun resolveActiveAgentName(activeAgentId: String): String { + if (activeAgentId.isNotEmpty()) { + gatewayAgents.firstOrNull { it.id == activeAgentId }?.let { agent -> + return normalized(agent.name) ?: agent.id + } + return activeAgentId + } + return gatewayAgents.firstOrNull()?.let { normalized(it.name) ?: it.id } ?: "Main" + } + + private fun homeCanvasAgents(activeAgentId: String): List { + val defaultAgentId = gatewayDefaultAgentId?.trim().orEmpty() + return gatewayAgents + .map { agent -> + val isActive = activeAgentId.isNotEmpty() && agent.id == activeAgentId + val isDefault = defaultAgentId.isNotEmpty() && agent.id == defaultAgentId + HomeCanvasAgentCard( + id = agent.id, + name = normalized(agent.name) ?: agent.id, + badge = homeCanvasBadge(agent), + caption = + when { + isActive -> "Active on this phone" + isDefault -> "Default agent" + else -> "Ready" + }, + isActive = isActive, + ) + }.sortedWith(compareByDescending { it.isActive }.thenBy { it.name.lowercase() }) + } + + private fun homeCanvasBadge(agent: GatewayAgentSummary): String { + val emoji = normalized(agent.emoji) + if (emoji != null) return emoji + val initials = + (normalized(agent.name) ?: agent.id) + .split(' ', '-', '_') + .filter { it.isNotBlank() } + .take(2) + .mapNotNull { token -> token.firstOrNull()?.uppercaseChar()?.toString() } + .joinToString("") + return if (initials.isNotEmpty()) initials else "OC" + } + + private fun normalized(value: String?): String? { + val trimmed = value?.trim().orEmpty() + return trimmed.ifEmpty { null } + } + private fun triggerCameraFlash() { // Token is used as a pulse trigger; value doesn't matter as long as it changes. _cameraFlashToken.value = SystemClock.elapsedRealtimeNanos() @@ -920,3 +1132,40 @@ class NodeRuntime(context: Context) { } } + +private enum class HomeCanvasGatewayState { + Connected, + Connecting, + Error, + Offline, +} + +private data class GatewayAgentSummary( + val id: String, + val name: String?, + val emoji: String?, +) + +@Serializable +private data class HomeCanvasPayload( + val gatewayState: String, + val eyebrow: String, + val title: String, + val subtitle: String, + val gatewayLabel: String, + val activeAgentName: String, + val activeAgentBadge: String, + val activeAgentCaption: String, + val agentCount: Int, + val agents: List, + val footer: String, +) + +@Serializable +private data class HomeCanvasAgentCard( + val id: String, + val name: String, + val badge: String, + val caption: String, + val isActive: Boolean, +) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt index b7e72ee4126..a1aabeb1b3c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SecurePrefs.kt @@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import java.util.UUID -class SecurePrefs(context: Context) { +class SecurePrefs( + context: Context, + private val securePrefsOverride: SharedPreferences? = null, +) { companion object { val defaultWakeWords: List = listOf("openclaw", "claude") private const val displayNameKey = "node.displayName" @@ -35,7 +38,7 @@ class SecurePrefs(context: Context) { .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() } - private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) } + private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) } private val _instanceId = MutableStateFlow(loadOrCreateInstanceId()) val instanceId: StateFlow = _instanceId @@ -76,6 +79,9 @@ class SecurePrefs(context: Context) { private val _gatewayToken = MutableStateFlow("") val gatewayToken: StateFlow = _gatewayToken + private val _gatewayBootstrapToken = MutableStateFlow("") + val gatewayBootstrapToken: StateFlow = _gatewayBootstrapToken + private val _onboardingCompleted = MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false)) val onboardingCompleted: StateFlow = _onboardingCompleted @@ -165,6 +171,10 @@ class SecurePrefs(context: Context) { saveGatewayPassword(value) } + fun setGatewayBootstrapToken(value: String) { + saveGatewayBootstrapToken(value) + } + fun setOnboardingCompleted(value: Boolean) { plainPrefs.edit { putBoolean("onboarding.completed", value) } _onboardingCompleted.value = value @@ -193,6 +203,26 @@ class SecurePrefs(context: Context) { securePrefs.edit { putString(key, token.trim()) } } + fun loadGatewayBootstrapToken(): String? { + val key = "gateway.bootstrapToken.${_instanceId.value}" + val stored = + _gatewayBootstrapToken.value.trim().ifEmpty { + val persisted = securePrefs.getString(key, null)?.trim().orEmpty() + if (persisted.isNotEmpty()) { + _gatewayBootstrapToken.value = persisted + } + persisted + } + return stored.takeIf { it.isNotEmpty() } + } + + fun saveGatewayBootstrapToken(token: String) { + val key = "gateway.bootstrapToken.${_instanceId.value}" + val trimmed = token.trim() + securePrefs.edit { putString(key, trimmed) } + _gatewayBootstrapToken.value = trimmed + } + fun loadGatewayPassword(): String? { val key = "gateway.password.${_instanceId.value}" val stored = securePrefs.getString(key, null)?.trim() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt index d1ac63a90ff..202ea4820e1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/DeviceAuthStore.kt @@ -5,6 +5,7 @@ import ai.openclaw.app.SecurePrefs interface DeviceAuthTokenStore { fun loadToken(deviceId: String, role: String): String? fun saveToken(deviceId: String, role: String, token: String) + fun clearToken(deviceId: String, role: String) } class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { @@ -18,7 +19,7 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore { prefs.putString(key, token.trim()) } - fun clearToken(deviceId: String, role: String) { + override fun clearToken(deviceId: String, role: String) { val key = tokenKey(deviceId, role) prefs.remove(key) } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt index aee47eaada8..55e371a57c7 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt @@ -52,6 +52,33 @@ data class GatewayConnectOptions( val userAgent: String? = null, ) +private enum class GatewayConnectAuthSource { + DEVICE_TOKEN, + SHARED_TOKEN, + BOOTSTRAP_TOKEN, + PASSWORD, + NONE, +} + +data class GatewayConnectErrorDetails( + val code: String?, + val canRetryWithDeviceToken: Boolean, + val recommendedNextStep: String?, +) + +private data class SelectedConnectAuth( + val authToken: String?, + val authBootstrapToken: String?, + val authDeviceToken: String?, + val authPassword: String?, + val signatureToken: String?, + val authSource: GatewayConnectAuthSource, + val attemptedDeviceTokenRetry: Boolean, +) + +private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) : + IllegalStateException(gatewayError.message) + class GatewaySession( private val scope: CoroutineScope, private val identityStore: DeviceIdentityStore, @@ -83,7 +110,11 @@ class GatewaySession( } } - data class ErrorShape(val code: String, val message: String) + data class ErrorShape( + val code: String, + val message: String, + val details: GatewayConnectErrorDetails? = null, + ) private val json = Json { ignoreUnknownKeys = true } private val writeLock = Mutex() @@ -95,6 +126,7 @@ class GatewaySession( private data class DesiredConnection( val endpoint: GatewayEndpoint, val token: String?, + val bootstrapToken: String?, val password: String?, val options: GatewayConnectOptions, val tls: GatewayTlsParams?, @@ -103,15 +135,22 @@ class GatewaySession( private var desired: DesiredConnection? = null private var job: Job? = null @Volatile private var currentConnection: Connection? = null + @Volatile private var pendingDeviceTokenRetry = false + @Volatile private var deviceTokenRetryBudgetUsed = false + @Volatile private var reconnectPausedForAuthFailure = false fun connect( endpoint: GatewayEndpoint, token: String?, + bootstrapToken: String?, password: String?, options: GatewayConnectOptions, tls: GatewayTlsParams? = null, ) { - desired = DesiredConnection(endpoint, token, password, options, tls) + desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls) + pendingDeviceTokenRetry = false + deviceTokenRetryBudgetUsed = false + reconnectPausedForAuthFailure = false if (job == null) { job = scope.launch(Dispatchers.IO) { runLoop() } } @@ -119,6 +158,9 @@ class GatewaySession( fun disconnect() { desired = null + pendingDeviceTokenRetry = false + deviceTokenRetryBudgetUsed = false + reconnectPausedForAuthFailure = false currentConnection?.closeQuietly() scope.launch(Dispatchers.IO) { job?.cancelAndJoin() @@ -130,6 +172,7 @@ class GatewaySession( } fun reconnect() { + reconnectPausedForAuthFailure = false currentConnection?.closeQuietly() } @@ -219,6 +262,7 @@ class GatewaySession( private inner class Connection( private val endpoint: GatewayEndpoint, private val token: String?, + private val bootstrapToken: String?, private val password: String?, private val options: GatewayConnectOptions, private val tls: GatewayTlsParams?, @@ -344,15 +388,48 @@ class GatewaySession( private suspend fun sendConnect(connectNonce: String) { val identity = identityStore.loadOrCreate() - val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role) - val trimmedToken = token?.trim().orEmpty() - // QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding. - val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty() - val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim()) + val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)?.trim() + val selectedAuth = + selectConnectAuth( + endpoint = endpoint, + tls = tls, + role = options.role, + explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() }, + explicitBootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() }, + explicitPassword = password?.trim()?.takeIf { it.isNotEmpty() }, + storedToken = storedToken?.takeIf { it.isNotEmpty() }, + ) + if (selectedAuth.attemptedDeviceTokenRetry) { + pendingDeviceTokenRetry = false + } + val payload = + buildConnectParams( + identity = identity, + connectNonce = connectNonce, + selectedAuth = selectedAuth, + ) val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS) if (!res.ok) { - val msg = res.error?.message ?: "connect failed" - throw IllegalStateException(msg) + val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed") + val shouldRetryWithDeviceToken = + shouldRetryWithStoredDeviceToken( + error = error, + explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() }, + storedToken = storedToken?.takeIf { it.isNotEmpty() }, + attemptedDeviceTokenRetry = selectedAuth.attemptedDeviceTokenRetry, + endpoint = endpoint, + tls = tls, + ) + if (shouldRetryWithDeviceToken) { + pendingDeviceTokenRetry = true + deviceTokenRetryBudgetUsed = true + } else if ( + selectedAuth.attemptedDeviceTokenRetry && + shouldClearStoredDeviceTokenAfterRetry(error) + ) { + deviceAuthStore.clearToken(identity.deviceId, options.role) + } + throw GatewayConnectFailure(error) } handleConnectSuccess(res, identity.deviceId) connectDeferred.complete(Unit) @@ -361,6 +438,9 @@ class GatewaySession( private fun handleConnectSuccess(res: RpcResponse, deviceId: String) { val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload") val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed") + pendingDeviceTokenRetry = false + deviceTokenRetryBudgetUsed = false + reconnectPausedForAuthFailure = false val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull() val authObj = obj["auth"].asObjectOrNull() val deviceToken = authObj?.get("deviceToken").asStringOrNull() @@ -380,8 +460,7 @@ class GatewaySession( private fun buildConnectParams( identity: DeviceIdentity, connectNonce: String, - authToken: String, - authPassword: String?, + selectedAuth: SelectedConnectAuth, ): JsonObject { val client = options.client val locale = Locale.getDefault().toLanguageTag() @@ -397,16 +476,20 @@ class GatewaySession( client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) } } - val password = authPassword?.trim().orEmpty() val authJson = when { - authToken.isNotEmpty() -> + selectedAuth.authToken != null -> buildJsonObject { - put("token", JsonPrimitive(authToken)) + put("token", JsonPrimitive(selectedAuth.authToken)) + selectedAuth.authDeviceToken?.let { put("deviceToken", JsonPrimitive(it)) } } - password.isNotEmpty() -> + selectedAuth.authBootstrapToken != null -> buildJsonObject { - put("password", JsonPrimitive(password)) + put("bootstrapToken", JsonPrimitive(selectedAuth.authBootstrapToken)) + } + selectedAuth.authPassword != null -> + buildJsonObject { + put("password", JsonPrimitive(selectedAuth.authPassword)) } else -> null } @@ -420,7 +503,7 @@ class GatewaySession( role = options.role, scopes = options.scopes, signedAtMs = signedAtMs, - token = if (authToken.isNotEmpty()) authToken else null, + token = selectedAuth.signatureToken, nonce = connectNonce, platform = client.platform, deviceFamily = client.deviceFamily, @@ -483,7 +566,16 @@ class GatewaySession( frame["error"]?.asObjectOrNull()?.let { obj -> val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE" val msg = obj["message"].asStringOrNull() ?: "request failed" - ErrorShape(code, msg) + val detailObj = obj["details"].asObjectOrNull() + val details = + detailObj?.let { + GatewayConnectErrorDetails( + code = it["code"].asStringOrNull(), + canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true, + recommendedNextStep = it["recommendedNextStep"].asStringOrNull(), + ) + } + ErrorShape(code, msg, details) } pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error)) } @@ -607,6 +699,10 @@ class GatewaySession( delay(250) continue } + if (reconnectPausedForAuthFailure) { + delay(250) + continue + } try { onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…") @@ -615,6 +711,13 @@ class GatewaySession( } catch (err: Throwable) { attempt += 1 onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}") + if ( + err is GatewayConnectFailure && + shouldPauseReconnectAfterAuthFailure(err.gatewayError) + ) { + reconnectPausedForAuthFailure = true + continue + } val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong()) delay(sleepMs) } @@ -622,7 +725,15 @@ class GatewaySession( } private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) { - val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls) + val conn = + Connection( + target.endpoint, + target.token, + target.bootstrapToken, + target.password, + target.options, + target.tls, + ) currentConnection = conn try { conn.connect() @@ -698,6 +809,100 @@ class GatewaySession( if (host == "0.0.0.0" || host == "::") return true return host.startsWith("127.") } + + private fun selectConnectAuth( + endpoint: GatewayEndpoint, + tls: GatewayTlsParams?, + role: String, + explicitGatewayToken: String?, + explicitBootstrapToken: String?, + explicitPassword: String?, + storedToken: String?, + ): SelectedConnectAuth { + val shouldUseDeviceRetryToken = + pendingDeviceTokenRetry && + explicitGatewayToken != null && + storedToken != null && + isTrustedDeviceRetryEndpoint(endpoint, tls) + val authToken = + explicitGatewayToken + ?: if ( + explicitPassword == null && + (explicitBootstrapToken == null || storedToken != null) + ) { + storedToken + } else { + null + } + val authDeviceToken = if (shouldUseDeviceRetryToken) storedToken else null + val authBootstrapToken = if (authToken == null) explicitBootstrapToken else null + val authSource = + when { + authDeviceToken != null || (explicitGatewayToken == null && authToken != null) -> + GatewayConnectAuthSource.DEVICE_TOKEN + authToken != null -> GatewayConnectAuthSource.SHARED_TOKEN + authBootstrapToken != null -> GatewayConnectAuthSource.BOOTSTRAP_TOKEN + explicitPassword != null -> GatewayConnectAuthSource.PASSWORD + else -> GatewayConnectAuthSource.NONE + } + return SelectedConnectAuth( + authToken = authToken, + authBootstrapToken = authBootstrapToken, + authDeviceToken = authDeviceToken, + authPassword = explicitPassword, + signatureToken = authToken ?: authBootstrapToken, + authSource = authSource, + attemptedDeviceTokenRetry = shouldUseDeviceRetryToken, + ) + } + + private fun shouldRetryWithStoredDeviceToken( + error: ErrorShape, + explicitGatewayToken: String?, + storedToken: String?, + attemptedDeviceTokenRetry: Boolean, + endpoint: GatewayEndpoint, + tls: GatewayTlsParams?, + ): Boolean { + if (deviceTokenRetryBudgetUsed) return false + if (attemptedDeviceTokenRetry) return false + if (explicitGatewayToken == null || storedToken == null) return false + if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false + val detailCode = error.details?.code + val recommendedNextStep = error.details?.recommendedNextStep + return error.details?.canRetryWithDeviceToken == true || + recommendedNextStep == "retry_with_device_token" || + detailCode == "AUTH_TOKEN_MISMATCH" + } + + private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean { + return when (error.details?.code) { + "AUTH_TOKEN_MISSING", + "AUTH_BOOTSTRAP_TOKEN_INVALID", + "AUTH_PASSWORD_MISSING", + "AUTH_PASSWORD_MISMATCH", + "AUTH_RATE_LIMITED", + "PAIRING_REQUIRED", + "CONTROL_UI_DEVICE_IDENTITY_REQUIRED", + "DEVICE_IDENTITY_REQUIRED" -> true + "AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry + else -> false + } + } + + private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean { + return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH" + } + + private fun isTrustedDeviceRetryEndpoint( + endpoint: GatewayEndpoint, + tls: GatewayTlsParams?, + ): Boolean { + if (isLoopbackHost(endpoint.host)) { + return true + } + return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true + } } private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt index 9efb2a924d7..0eab9d75a5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/CanvasController.kt @@ -34,6 +34,7 @@ class CanvasController { @Volatile private var debugStatusEnabled: Boolean = false @Volatile private var debugStatusTitle: String? = null @Volatile private var debugStatusSubtitle: String? = null + @Volatile private var homeCanvasStateJson: String? = null private val _currentUrl = MutableStateFlow(null) val currentUrl: StateFlow = _currentUrl.asStateFlow() @@ -56,6 +57,7 @@ class CanvasController { this.webView = webView reload() applyDebugStatus() + applyHomeCanvasState() } fun detach(webView: WebView) { @@ -88,6 +90,12 @@ class CanvasController { fun onPageFinished() { applyDebugStatus() + applyHomeCanvasState() + } + + fun updateHomeCanvasState(json: String?) { + homeCanvasStateJson = json + applyHomeCanvasState() } private inline fun withWebViewOnMain(crossinline block: (WebView) -> Unit) { @@ -142,6 +150,22 @@ class CanvasController { } } + private fun applyHomeCanvasState() { + val payload = homeCanvasStateJson ?: "null" + withWebViewOnMain { wv -> + val js = """ + (() => { + try { + const api = globalThis.__openclaw; + if (!api || typeof api.renderHome !== 'function') return; + api.renderHome($payload); + } catch (_) {} + })(); + """.trimIndent() + wv.evaluateJavascript(js, null) + } + } + suspend fun eval(javaScript: String): String = withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") 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 4b8ac2c8e5d..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,93 +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) - if (config.token.isNotBlank()) { - viewModel.setGatewayToken(config.token) - } - 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/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 93b4fc1bb60..3416900ed5b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -1,8 +1,8 @@ package ai.openclaw.app.ui -import androidx.core.net.toUri import java.util.Base64 import java.util.Locale +import java.net.URI import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig( internal data class GatewaySetupCode( val url: String, + val bootstrapToken: String?, val token: String?, val password: String?, ) @@ -26,6 +27,7 @@ internal data class GatewayConnectConfig( val host: String, val port: Int, val tls: Boolean, + val bootstrapToken: String, val token: String, val password: String, ) @@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig( if (useSetupCode) { val setup = decodeGatewaySetupCode(setupCode) ?: return null val parsed = parseGatewayEndpoint(setup.url) ?: return null + val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty() + val sharedToken = + when { + !setup.token.isNullOrBlank() -> setup.token.trim() + setupBootstrapToken.isNotEmpty() -> "" + else -> fallbackToken.trim() + } + val sharedPassword = + when { + !setup.password.isNullOrBlank() -> setup.password.trim() + setupBootstrapToken.isNotEmpty() -> "" + else -> fallbackPassword.trim() + } return GatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, - token = setup.token ?: fallbackToken.trim(), - password = setup.password ?: fallbackPassword.trim(), + bootstrapToken = setupBootstrapToken, + token = sharedToken, + password = sharedPassword, ) } @@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, + bootstrapToken = "", token = fallbackToken.trim(), password = fallbackPassword.trim(), ) @@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { if (raw.isEmpty()) return null val normalized = if (raw.contains("://")) raw else "https://$raw" - val uri = normalized.toUri() + val uri = runCatching { URI(normalized) }.getOrNull() ?: return null val host = uri.host?.trim().orEmpty() if (host.isEmpty()) return null @@ -80,7 +97,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? { "wss", "https" -> true else -> true } - val port = uri.port.takeIf { it in 1..65535 } ?: 18789 + val port = uri.port.takeIf { it in 1..65535 } ?: if (tls) 443 else 18789 val displayUrl = "${if (tls) "https" else "http"}://$host:$port" return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl) @@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? { val obj = parseJsonObject(decoded) ?: return null val url = jsonField(obj, "url").orEmpty() if (url.isEmpty()) return null + val bootstrapToken = jsonField(obj, "bootstrapToken") val token = jsonField(obj, "token") val password = jsonField(obj, "password") - GatewaySetupCode(url = url, token = token, password = password) + GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password) } catch (_: IllegalArgumentException) { null } 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 8810ea93fcb..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 = { @@ -772,8 +781,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { return@Button } gatewayUrl = parsedSetup.url - parsedSetup.token?.let { viewModel.setGatewayToken(it) } - gatewayPassword = parsedSetup.password.orEmpty() + viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty()) + val sharedToken = parsedSetup.token.orEmpty().trim() + val password = parsedSetup.password.orEmpty().trim() + if (sharedToken.isNotEmpty()) { + viewModel.setGatewayToken(sharedToken) + } else if (!parsedSetup.bootstrapToken.isNullOrBlank()) { + viewModel.setGatewayToken("") + } + gatewayPassword = password + if (password.isEmpty() && !parsedSetup.bootstrapToken.isNullOrBlank()) { + viewModel.setGatewayPassword("") + } } else { val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls) val parsedGateway = manualUrl?.let(::parseGatewayEndpoint) @@ -782,6 +801,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { return@Button } gatewayUrl = parsedGateway.displayUrl + viewModel.setGatewayBootstrapToken("") } step = OnboardingStep.Permissions }, @@ -850,8 +870,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { viewModel.setManualHost(parsed.host) viewModel.setManualPort(parsed.port) viewModel.setManualTls(parsed.tls) + if (gatewayInputMode == GatewayInputMode.Manual) { + viewModel.setGatewayBootstrapToken("") + } if (token.isNotEmpty()) { viewModel.setGatewayToken(token) + } else { + viewModel.setGatewayToken("") } viewModel.setGatewayPassword(password) viewModel.connectManual() @@ -876,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 @@ -926,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), + ) } } @@ -959,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), @@ -1007,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) { @@ -1290,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() } } @@ -1362,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, @@ -1376,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, @@ -1385,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, @@ -1394,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, @@ -1410,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, @@ -1418,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, @@ -1434,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, @@ -1442,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, @@ -1453,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, @@ -1473,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, @@ -1481,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, @@ -1513,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) @@ -1537,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) + } + } } } @@ -1555,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), @@ -1570,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) + } + } } } @@ -1594,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/PostOnboardingTabs.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt index 0642f9b3a7e..c3a14fe5a54 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/PostOnboardingTabs.kt @@ -134,43 +134,14 @@ fun PostOnboardingTabs(viewModel: MainViewModel, modifier: Modifier = Modifier) @Composable private fun ScreenTabScreen(viewModel: MainViewModel) { val isConnected by viewModel.isConnected.collectAsState() - val isNodeConnected by viewModel.isNodeConnected.collectAsState() - val canvasUrl by viewModel.canvasCurrentUrl.collectAsState() - val canvasA2uiHydrated by viewModel.canvasA2uiHydrated.collectAsState() - val canvasRehydratePending by viewModel.canvasRehydratePending.collectAsState() - val canvasRehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState() - val isA2uiUrl = canvasUrl?.contains("/__openclaw__/a2ui/") == true - val showRestoreCta = isConnected && isNodeConnected && (canvasUrl.isNullOrBlank() || (isA2uiUrl && !canvasA2uiHydrated)) - val restoreCtaText = - when { - canvasRehydratePending -> "Restore requested. Waiting for agent…" - !canvasRehydrateErrorText.isNullOrBlank() -> canvasRehydrateErrorText!! - else -> "Canvas reset. Tap to restore dashboard." + LaunchedEffect(isConnected) { + if (isConnected) { + viewModel.refreshHomeCanvasOverviewIfConnected() } + } Box(modifier = Modifier.fillMaxSize()) { CanvasScreen(viewModel = viewModel, modifier = Modifier.fillMaxSize()) - - if (showRestoreCta) { - Surface( - onClick = { - if (canvasRehydratePending) return@Surface - viewModel.requestCanvasRehydrate(source = "screen_tab_cta") - }, - modifier = Modifier.align(Alignment.TopCenter).padding(horizontal = 16.dp, vertical = 16.dp), - shape = RoundedCornerShape(12.dp), - color = mobileSurface.copy(alpha = 0.9f), - border = BorderStroke(1.dp, mobileBorder), - shadowElevation = 4.dp, - ) { - Text( - text = restoreCtaText, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - style = mobileCallout.copy(fontWeight = FontWeight.Medium), - color = mobileText, - ) - } - } } } 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/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt index cd72bf75dff..1ef860e29b4 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SecurePrefsTest.kt @@ -20,4 +20,19 @@ class SecurePrefsTest { assertEquals(LocationMode.WhileUsing, prefs.locationMode.value) assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null)) } + + @Test + fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() { + val context = RuntimeEnvironment.getApplication() + val securePrefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE) + securePrefs.edit().clear().commit() + val prefs = SecurePrefs(context, securePrefsOverride = securePrefs) + + prefs.setGatewayToken("shared-token") + prefs.setGatewayBootstrapToken("bootstrap-token") + + assertEquals("shared-token", prefs.loadGatewayToken()) + assertEquals("bootstrap-token", prefs.loadGatewayBootstrapToken()) + assertEquals("bootstrap-token", prefs.gatewayBootstrapToken.value) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt index a3f301498c8..2cfa1be4866 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/gateway/GatewaySessionInvokeTest.kt @@ -27,6 +27,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference private const val TEST_TIMEOUT_MS = 8_000L @@ -41,11 +42,16 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore { override fun saveToken(deviceId: String, role: String, token: String) { tokens["${deviceId.trim()}|${role.trim()}"] = token.trim() } + + override fun clearToken(deviceId: String, role: String) { + tokens.remove("${deviceId.trim()}|${role.trim()}") + } } private data class NodeHarness( val session: GatewaySession, val sessionJob: Job, + val deviceAuthStore: InMemoryDeviceAuthStore, ) private data class InvokeScenarioResult( @@ -56,6 +62,157 @@ private data class InvokeScenarioResult( @RunWith(RobolectricTestRunner::class) @Config(sdk = [34]) class GatewaySessionInvokeTest { + @Test + fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectAuth = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectAuth.isCompleted) { + connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() } + assertEquals("bootstrap-token", auth?.get("bootstrapToken")?.jsonPrimitive?.content) + assertNull(auth?.get("token")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_prefersStoredDeviceTokenOverBootstrapToken() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val connectAuth = CompletableDeferred() + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + if (!connectAuth.isCompleted) { + connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken(deviceId, "node", "device-token") + + connectNodeSession( + session = harness.session, + port = server.port, + token = null, + bootstrapToken = "bootstrap-token", + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() } + assertEquals("device-token", auth?.get("token")?.jsonPrimitive?.content) + assertNull(auth?.get("bootstrapToken")) + } finally { + shutdownHarness(harness, server) + } + } + + @Test + fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() = runBlocking { + val json = testJson() + val connected = CompletableDeferred() + val firstConnectAuth = CompletableDeferred() + val secondConnectAuth = CompletableDeferred() + val connectAttempts = AtomicInteger(0) + val lastDisconnect = AtomicReference("") + val server = + startGatewayServer(json) { webSocket, id, method, frame -> + when (method) { + "connect" -> { + val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject + when (connectAttempts.incrementAndGet()) { + 1 -> { + if (!firstConnectAuth.isCompleted) { + firstConnectAuth.complete(auth) + } + webSocket.send( + """{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}}}""", + ) + webSocket.close(1000, "retry") + } + else -> { + if (!secondConnectAuth.isCompleted) { + secondConnectAuth.complete(auth) + } + webSocket.send(connectResponseFrame(id)) + webSocket.close(1000, "done") + } + } + } + } + } + + val harness = + createNodeHarness( + connected = connected, + lastDisconnect = lastDisconnect, + ) { GatewaySession.InvokeResult.ok("""{"handled":true}""") } + + try { + val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId + harness.deviceAuthStore.saveToken(deviceId, "node", "stored-device-token") + + connectNodeSession( + session = harness.session, + port = server.port, + token = "shared-auth-token", + bootstrapToken = null, + ) + awaitConnectedOrThrow(connected, lastDisconnect, server) + + val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() } + val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() } + assertEquals("shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content) + assertNull(firstAuth?.get("deviceToken")) + assertEquals("shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content) + assertEquals("stored-device-token", secondAuth?.get("deviceToken")?.jsonPrimitive?.content) + } finally { + shutdownHarness(harness, server) + } + } + @Test fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking { val handshakeOrigin = AtomicReference(null) @@ -182,11 +339,12 @@ class GatewaySessionInvokeTest { ): NodeHarness { val app = RuntimeEnvironment.getApplication() val sessionJob = SupervisorJob() + val deviceAuthStore = InMemoryDeviceAuthStore() val session = GatewaySession( scope = CoroutineScope(sessionJob + Dispatchers.Default), identityStore = DeviceIdentityStore(app), - deviceAuthStore = InMemoryDeviceAuthStore(), + deviceAuthStore = deviceAuthStore, onConnected = { _, _, _ -> if (!connected.isCompleted) connected.complete(Unit) }, @@ -197,10 +355,15 @@ class GatewaySessionInvokeTest { onInvoke = onInvoke, ) - return NodeHarness(session = session, sessionJob = sessionJob) + return NodeHarness(session = session, sessionJob = sessionJob, deviceAuthStore = deviceAuthStore) } - private suspend fun connectNodeSession(session: GatewaySession, port: Int) { + private suspend fun connectNodeSession( + session: GatewaySession, + port: Int, + token: String? = "test-token", + bootstrapToken: String? = null, + ) { session.connect( endpoint = GatewayEndpoint( @@ -210,7 +373,8 @@ class GatewaySessionInvokeTest { port = port, tlsEnabled = false, ), - token = "test-token", + token = token, + bootstrapToken = bootstrapToken, password = null, options = GatewayConnectOptions( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index 72738843ff0..5c24631cf0b 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -8,7 +8,8 @@ import org.junit.Test class GatewayConfigResolverTest { @Test fun resolveScannedSetupCodeAcceptsRawSetupCode() { - val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""") + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") val resolved = resolveScannedSetupCode(setupCode) @@ -17,7 +18,8 @@ class GatewayConfigResolverTest { @Test fun resolveScannedSetupCodeAcceptsQrJsonPayload() { - val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""") + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") val qrJson = """ { @@ -53,6 +55,67 @@ class GatewayConfigResolverTest { assertNull(resolved) } + @Test + fun decodeGatewaySetupCodeParsesBootstrapToken() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + + val decoded = decodeGatewaySetupCode(setupCode) + + assertEquals("wss://gateway.example:18789", decoded?.url) + assertEquals("bootstrap-1", decoded?.bootstrapToken) + assertNull(decoded?.token) + assertNull(decoded?.password) + } + + @Test + fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHost = "", + manualPort = "", + manualTls = true, + fallbackToken = "shared-token", + fallbackPassword = "shared-password", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("bootstrap-1", resolved?.bootstrapToken) + assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) + assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) + } + + @Test + fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() { + val setupCode = + encodeSetupCode("""{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""") + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHost = "", + manualPort = "", + manualTls = true, + fallbackToken = "shared-token", + fallbackPassword = "shared-password", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(443, resolved?.port) + assertEquals(true, resolved?.tls) + assertEquals("bootstrap-1", resolved?.bootstrapToken) + assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) + assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) + } + private fun encodeSetupCode(payloadJson: String): String { return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) } diff --git a/apps/android/scripts/build-release-aab.ts b/apps/android/scripts/build-release-aab.ts new file mode 100644 index 00000000000..30e4bb0390b --- /dev/null +++ b/apps/android/scripts/build-release-aab.ts @@ -0,0 +1,125 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const androidDir = join(scriptDir, ".."); +const buildGradlePath = join(androidDir, "app", "build.gradle.kts"); +const bundlePath = join(androidDir, "app", "build", "outputs", "bundle", "release", "app-release.aab"); + +type VersionState = { + versionName: string; + versionCode: number; +}; + +type ParsedVersionMatches = { + versionNameMatch: RegExpMatchArray; + versionCodeMatch: RegExpMatchArray; +}; + +function formatVersionName(date: Date): string { + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}.${month}.${day}`; +} + +function formatVersionCodePrefix(date: Date): string { + const year = date.getFullYear().toString(); + const month = (date.getMonth() + 1).toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2, "0"); + return `${year}${month}${day}`; +} + +function parseVersionMatches(buildGradleText: string): ParsedVersionMatches { + const versionCodeMatch = buildGradleText.match(/versionCode = (\d+)/); + const versionNameMatch = buildGradleText.match(/versionName = "([^"]+)"/); + if (!versionCodeMatch || !versionNameMatch) { + throw new Error(`Couldn't parse versionName/versionCode from ${buildGradlePath}`); + } + return { versionCodeMatch, versionNameMatch }; +} + +function resolveNextVersionCode(currentVersionCode: number, todayPrefix: string): number { + const currentRaw = currentVersionCode.toString(); + let nextSuffix = 0; + + if (currentRaw.startsWith(todayPrefix)) { + const suffixRaw = currentRaw.slice(todayPrefix.length); + nextSuffix = (suffixRaw ? Number.parseInt(suffixRaw, 10) : 0) + 1; + } + + if (!Number.isInteger(nextSuffix) || nextSuffix < 0 || nextSuffix > 99) { + throw new Error( + `Can't auto-bump Android versionCode for ${todayPrefix}: next suffix ${nextSuffix} is invalid`, + ); + } + + return Number.parseInt(`${todayPrefix}${nextSuffix.toString().padStart(2, "0")}`, 10); +} + +function resolveNextVersion(buildGradleText: string, date: Date): VersionState { + const { versionCodeMatch } = parseVersionMatches(buildGradleText); + const currentVersionCode = Number.parseInt(versionCodeMatch[1] ?? "", 10); + if (!Number.isInteger(currentVersionCode)) { + throw new Error(`Invalid Android versionCode in ${buildGradlePath}`); + } + + const versionName = formatVersionName(date); + const versionCode = resolveNextVersionCode(currentVersionCode, formatVersionCodePrefix(date)); + return { versionName, versionCode }; +} + +function updateBuildGradleVersions(buildGradleText: string, nextVersion: VersionState): string { + return buildGradleText + .replace(/versionCode = \d+/, `versionCode = ${nextVersion.versionCode}`) + .replace(/versionName = "[^"]+"/, `versionName = "${nextVersion.versionName}"`); +} + +async function sha256Hex(path: string): Promise { + const buffer = await Bun.file(path).arrayBuffer(); + const digest = await crypto.subtle.digest("SHA-256", buffer); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +async function verifyBundleSignature(path: string): Promise { + await $`jarsigner -verify ${path}`.quiet(); +} + +async function main() { + const buildGradleFile = Bun.file(buildGradlePath); + const originalText = await buildGradleFile.text(); + const nextVersion = resolveNextVersion(originalText, new Date()); + const updatedText = updateBuildGradleVersions(originalText, nextVersion); + + if (updatedText === originalText) { + throw new Error("Android version bump produced no change"); + } + + console.log(`Android versionName -> ${nextVersion.versionName}`); + console.log(`Android versionCode -> ${nextVersion.versionCode}`); + + await Bun.write(buildGradlePath, updatedText); + + try { + await $`./gradlew :app:bundleRelease`.cwd(androidDir); + } catch (error) { + await Bun.write(buildGradlePath, originalText); + throw error; + } + + const bundleFile = Bun.file(bundlePath); + if (!(await bundleFile.exists())) { + throw new Error(`Signed bundle missing at ${bundlePath}`); + } + + await verifyBundleSignature(bundlePath); + const hash = await sha256Hex(bundlePath); + + console.log(`Signed AAB: ${bundlePath}`); + console.log(`SHA-256: ${hash}`); +} + +await main(); diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift index 836803f403f..497fbd45a08 100644 --- a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift +++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift @@ -47,6 +47,7 @@ struct OpenClawLiveActivity: Widget { Spacer() trailingView(state: context.state) } + .padding(.horizontal, 12) .padding(.vertical, 4) } diff --git a/apps/ios/Config/Version.xcconfig b/apps/ios/Config/Version.xcconfig index db38e86df80..4297bc8ff57 100644 --- a/apps/ios/Config/Version.xcconfig +++ b/apps/ios/Config/Version.xcconfig @@ -1,8 +1,8 @@ // Shared iOS version defaults. // Generated overrides live in build/Version.xcconfig (git-ignored). -OPENCLAW_GATEWAY_VERSION = 0.0.0 -OPENCLAW_MARKETING_VERSION = 0.0.0 -OPENCLAW_BUILD_VERSION = 0 +OPENCLAW_GATEWAY_VERSION = 2026.3.14 +OPENCLAW_MARKETING_VERSION = 2026.3.14 +OPENCLAW_BUILD_VERSION = 202603140 #include? "../build/Version.xcconfig" diff --git a/apps/ios/README.md b/apps/ios/README.md index 6eb35a1d639..8e591839bd0 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -62,11 +62,17 @@ Release behavior: - Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`. - Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`. +- 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.11-beta.1` becomes: - - `CFBundleShortVersionString = 2026.3.11` - - `CFBundleVersion = next TestFlight build number for 2026.3.11` +- 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: + +- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com` + This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters. Archive without upload: @@ -91,9 +97,43 @@ pnpm ios:beta -- --build-number 7 - The app calls `registerForRemoteNotifications()` at launch. - `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`. - APNs token registration to gateway happens only after gateway connection (`push.apns.register`). +- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`. - Your selected team/profile must support Push Notifications for the app bundle ID you are signing. - If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`). -- Debug builds register as APNs sandbox; Release builds use production. +- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`. + +## APNs Expectations For Official Builds + +- Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway. +- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, relay origin metadata, and installation metadata instead of the raw APNs token. +- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration. +- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect. +- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin. +- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration. +- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only. + +## Official Build Relay Trust Model + +- `iOS -> gateway` + - The app must pair with the gateway and establish both node and operator sessions. + - The operator session is used to fetch `gateway.identity.get`. +- `iOS -> relay` + - The app registers with the relay over HTTPS using App Attest plus the app receipt. + - The relay requires the official production/TestFlight distribution path, which is why local + Xcode/dev installs cannot use the hosted relay. +- `gateway delegation` + - The app includes the gateway identity in relay registration. + - The relay returns a relay handle and registration-scoped send grant delegated to that gateway. +- `gateway -> relay` + - The gateway signs relay send requests with its own device identity. + - The relay verifies both the delegated send grant and the gateway signature before it sends to + APNs. +- `relay -> APNs` + - Production APNs credentials and raw official-build APNs tokens stay in the relay deployment, + not on the gateway. + +This exists to keep the hosted relay limited to genuine OpenClaw official builds and to ensure a +gateway can only send pushes for iOS devices that paired with that gateway. ## What Works Now (Concrete) 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/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 67f01138803..297811d3ee7 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -39,6 +39,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { // (chat.subscribe is a node event, not an operator RPC method.) } + func resetSession(sessionKey: String) async throws { + struct Params: Codable { var key: String } + let data = try JSONEncoder().encode(Params(key: sessionKey)) + let json = String(data: data, encoding: .utf8) + _ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10) + } + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { struct Params: Codable { var sessionKey: String } let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) diff --git a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift index 7f4e93380b0..0abea0e312c 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectConfig.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectConfig.swift @@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable { let stableID: String let tls: GatewayTLSParams? let token: String? + let bootstrapToken: String? let password: String? let nodeOptions: GatewayConnectOptions diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index 259768a4df1..dc94f3d0797 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -101,6 +101,7 @@ final class GatewayConnectionController { return "Missing instanceId (node.instanceId). Try restarting the app." } let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) // Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT. @@ -151,6 +152,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) return nil } @@ -163,6 +165,7 @@ final class GatewayConnectionController { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS) guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS) @@ -203,6 +206,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) } @@ -229,6 +233,7 @@ final class GatewayConnectionController { stableID: cfg.stableID, tls: cfg.tls, token: cfg.token, + bootstrapToken: cfg.bootstrapToken, password: cfg.password, nodeOptions: self.makeConnectOptions(stableID: cfg.stableID)) appModel.applyGatewayConnectConfig(refreshedConfig) @@ -261,6 +266,7 @@ final class GatewayConnectionController { let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) let tlsParams = GatewayTLSParams( required: true, @@ -274,6 +280,7 @@ final class GatewayConnectionController { gatewayStableID: pending.stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) } @@ -319,6 +326,7 @@ final class GatewayConnectionController { guard !instanceId.isEmpty else { return } let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId) + let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId) let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId) if manualEnabled { @@ -353,6 +361,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) return } @@ -379,6 +388,7 @@ final class GatewayConnectionController { gatewayStableID: stableID, tls: tlsParams, token: token, + bootstrapToken: bootstrapToken, password: password) return } @@ -448,6 +458,7 @@ final class GatewayConnectionController { gatewayStableID: String, tls: GatewayTLSParams?, token: String?, + bootstrapToken: String?, password: String?) { guard let appModel else { return } @@ -463,6 +474,7 @@ final class GatewayConnectionController { stableID: gatewayStableID, tls: tls, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions) appModel.applyGatewayConnectConfig(cfg) diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 37c039d69d1..92dc71259e5 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -104,6 +104,21 @@ enum GatewaySettingsStore { account: self.gatewayTokenAccount(instanceId: instanceId)) } + static func loadGatewayBootstrapToken(instanceId: String) -> String? { + let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId) + let token = KeychainStore.loadString(service: self.gatewayService, account: account)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if token?.isEmpty == false { return token } + return nil + } + + static func saveGatewayBootstrapToken(_ token: String, instanceId: String) { + _ = KeychainStore.saveString( + token, + service: self.gatewayService, + account: self.gatewayBootstrapTokenAccount(instanceId: instanceId)) + } + static func loadGatewayPassword(instanceId: String) -> String? { KeychainStore.loadString( service: self.gatewayService, @@ -278,6 +293,9 @@ enum GatewaySettingsStore { _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayTokenAccount(instanceId: trimmed)) + _ = KeychainStore.delete( + service: self.gatewayService, + account: self.gatewayBootstrapTokenAccount(instanceId: trimmed)) _ = KeychainStore.delete( service: self.gatewayService, account: self.gatewayPasswordAccount(instanceId: trimmed)) @@ -331,6 +349,10 @@ enum GatewaySettingsStore { "gateway-token.\(instanceId)" } + private static func gatewayBootstrapTokenAccount(instanceId: String) -> String { + "gateway-bootstrap-token.\(instanceId)" + } + private static func gatewayPasswordAccount(instanceId: String) -> String { "gateway-password.\(instanceId)" } diff --git a/apps/ios/Sources/Gateway/GatewaySetupCode.swift b/apps/ios/Sources/Gateway/GatewaySetupCode.swift index 8ccbab42da7..d52ca023563 100644 --- a/apps/ios/Sources/Gateway/GatewaySetupCode.swift +++ b/apps/ios/Sources/Gateway/GatewaySetupCode.swift @@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable { var host: String? var port: Int? var tls: Bool? + var bootstrapToken: String? var token: String? var password: String? } @@ -39,4 +40,3 @@ enum GatewaySetupCode { return String(data: data, encoding: .utf8) } } - diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 892d53e7ae9..5908021fad3 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -66,6 +66,14 @@ OpenClaw uses on-device speech recognition for voice wake. NSSupportsLiveActivities + OpenClawPushAPNsEnvironment + $(OPENCLAW_PUSH_APNS_ENVIRONMENT) + OpenClawPushDistribution + $(OPENCLAW_PUSH_DISTRIBUTION) + OpenClawPushRelayBaseURL + $(OPENCLAW_PUSH_RELAY_BASE_URL) + OpenClawPushTransport + $(OPENCLAW_PUSH_TRANSPORT) UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 685b30f0887..4c0ab81f1a1 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -12,6 +12,12 @@ import UserNotifications private struct NotificationCallError: Error, Sendable { let message: String } + +private struct GatewayRelayIdentityResponse: Decodable { + let deviceId: String + let publicKey: String +} + // Ensures notification requests return promptly even if the system prompt blocks. private final class NotificationInvokeLatch: @unchecked Sendable { private let lock = NSLock() @@ -140,6 +146,7 @@ final class NodeAppModel { private var shareDeliveryTo: String? private var apnsDeviceTokenHex: String? private var apnsLastRegisteredTokenHex: String? + @ObservationIgnored private let pushRegistrationManager = PushRegistrationManager() var gatewaySession: GatewayNodeSession { self.nodeGateway } var operatorSession: GatewayNodeSession { self.operatorGateway } private(set) var activeGatewayConnectConfig: GatewayConnectConfig? @@ -528,13 +535,6 @@ final class NodeAppModel { private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex" private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key" private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey() - private static var apnsEnvironment: String { -#if DEBUG - "sandbox" -#else - "production" -#endif - } private func refreshBrandingFromGateway() async { do { @@ -1189,7 +1189,15 @@ final class NodeAppModel { _ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) } - return await self.notificationAuthorizationStatus() + let updatedStatus = await self.notificationAuthorizationStatus() + if Self.isNotificationAuthorizationAllowed(updatedStatus) { + // Refresh APNs registration immediately after the first permission grant so the + // gateway can receive a push registration without requiring an app relaunch. + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } + return updatedStatus } private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus { @@ -1204,6 +1212,17 @@ final class NodeAppModel { } } + private static func isNotificationAuthorizationAllowed( + _ status: NotificationAuthorizationStatus + ) -> Bool { + switch status { + case .authorized, .provisional, .ephemeral: + true + case .denied, .notDetermined: + false + } + } + private func runNotificationCall( timeoutSeconds: Double, operation: @escaping @Sendable () async throws -> T @@ -1661,6 +1680,7 @@ extension NodeAppModel { gatewayStableID: String, tls: GatewayTLSParams?, token: String?, + bootstrapToken: String?, password: String?, connectOptions: GatewayConnectOptions) { @@ -1673,6 +1693,7 @@ extension NodeAppModel { stableID: stableID, tls: tls, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions) self.prepareForGatewayConnect(url: url, stableID: effectiveStableID) @@ -1680,6 +1701,7 @@ extension NodeAppModel { url: url, stableID: effectiveStableID, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions, sessionBox: sessionBox) @@ -1687,6 +1709,7 @@ extension NodeAppModel { url: url, stableID: effectiveStableID, token: token, + bootstrapToken: bootstrapToken, password: password, nodeOptions: connectOptions, sessionBox: sessionBox) @@ -1702,6 +1725,7 @@ extension NodeAppModel { gatewayStableID: cfg.stableID, tls: cfg.tls, token: cfg.token, + bootstrapToken: cfg.bootstrapToken, password: cfg.password, connectOptions: cfg.nodeOptions) } @@ -1782,6 +1806,7 @@ private extension NodeAppModel { url: URL, stableID: String, token: String?, + bootstrapToken: String?, password: String?, nodeOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?) @@ -1819,6 +1844,7 @@ private extension NodeAppModel { try await self.operatorGateway.connect( url: url, token: token, + bootstrapToken: bootstrapToken, password: password, connectOptions: operatorOptions, sessionBox: sessionBox, @@ -1834,6 +1860,7 @@ private extension NodeAppModel { await self.refreshBrandingFromGateway() await self.refreshAgentsFromGateway() await self.refreshShareRouteFromGateway() + await self.registerAPNsTokenIfNeeded() await self.startVoiceWakeSync() await MainActor.run { LiveActivityManager.shared.handleReconnect() } await MainActor.run { self.startGatewayHealthMonitor() } @@ -1876,6 +1903,7 @@ private extension NodeAppModel { url: URL, stableID: String, token: String?, + bootstrapToken: String?, password: String?, nodeOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?) @@ -1924,6 +1952,7 @@ private extension NodeAppModel { try await self.nodeGateway.connect( url: url, token: token, + bootstrapToken: bootstrapToken, password: password, connectOptions: currentOptions, sessionBox: sessionBox, @@ -2479,7 +2508,8 @@ extension NodeAppModel { else { return } - if token == self.apnsLastRegisteredTokenHex { + let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport + if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex { return } guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -2488,25 +2518,40 @@ extension NodeAppModel { return } - struct PushRegistrationPayload: Codable { - var token: String - var topic: String - var environment: String - } - - let payload = PushRegistrationPayload( - token: token, - topic: topic, - environment: Self.apnsEnvironment) do { - let json = try Self.encodePayload(payload) - await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json) + let gatewayIdentity: PushRelayGatewayIdentity? + if usesRelayTransport { + guard self.operatorConnected else { return } + gatewayIdentity = try await self.fetchPushRelayGatewayIdentity() + } else { + gatewayIdentity = nil + } + let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload( + apnsTokenHex: token, + topic: topic, + gatewayIdentity: gatewayIdentity) + await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON) self.apnsLastRegisteredTokenHex = token } catch { - // Best-effort only. + self.pushWakeLogger.error( + "APNs registration publish failed: \(error.localizedDescription, privacy: .public)") } } + private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity { + let response = try await self.operatorGateway.request( + method: "gateway.identity.get", + paramsJSON: "{}", + timeoutSeconds: 8) + let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response) + let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines) + let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !deviceId.isEmpty, !publicKey.isEmpty else { + throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields") + } + return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey) + } + private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool { guard let apsAny = userInfo["aps"] else { return false } if let aps = apsAny as? [AnyHashable: Any] { diff --git a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift index b8b6e267755..f160b37d798 100644 --- a/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift +++ b/apps/ios/Sources/Onboarding/GatewayOnboardingView.swift @@ -275,9 +275,21 @@ private struct ManualEntryStep: View { if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + self.manualToken = "" } if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) + } else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { + self.manualPassword = "" + } + + let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedInstanceId.isEmpty { + let trimmedBootstrapToken = + payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId) } self.setupStatusText = "Setup code applied." 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 4cefeb77e74..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) } } @@ -642,11 +749,17 @@ struct OnboardingWizardView: View { self.manualHost = link.host self.manualPort = link.port self.manualTLS = link.tls - if let token = link.token { + let trimmedBootstrapToken = link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) + self.saveGatewayBootstrapToken(trimmedBootstrapToken) + if let token = link.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty { self.gatewayToken = token + } else if trimmedBootstrapToken?.isEmpty == false { + self.gatewayToken = "" } - if let password = link.password { + if let password = link.password?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty { self.gatewayPassword = password + } else if trimmedBootstrapToken?.isEmpty == false { + self.gatewayPassword = "" } self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword) self.showQRScanner = false @@ -721,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 @@ -769,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." } } @@ -794,6 +911,13 @@ struct OnboardingWizardView: View { GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) } + private func saveGatewayBootstrapToken(_ token: String?) { + let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedInstanceId.isEmpty else { return } + let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId) + } + private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async { self.connectingGatewayID = gateway.id self.issue = .none diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index c94b1209f8d..ae980b0216a 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge { let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false if !granted { return false } let updatedStatus = await self.notificationAuthorizationStatus(center: center) + if self.isAuthorizationStatusAllowed(updatedStatus) { + // Refresh APNs registration immediately after the first permission grant so the + // gateway can receive a push registration without requiring an app relaunch. + await MainActor.run { + UIApplication.shared.registerForRemoteNotifications() + } + } return self.isAuthorizationStatusAllowed(updatedStatus) case .denied: return false diff --git a/apps/ios/Sources/Push/PushBuildConfig.swift b/apps/ios/Sources/Push/PushBuildConfig.swift new file mode 100644 index 00000000000..d1665921552 --- /dev/null +++ b/apps/ios/Sources/Push/PushBuildConfig.swift @@ -0,0 +1,75 @@ +import Foundation + +enum PushTransportMode: String { + case direct + case relay +} + +enum PushDistributionMode: String { + case local + case official +} + +enum PushAPNsEnvironment: String { + case sandbox + case production +} + +struct PushBuildConfig { + let transport: PushTransportMode + let distribution: PushDistributionMode + let relayBaseURL: URL? + let apnsEnvironment: PushAPNsEnvironment + + static let current = PushBuildConfig() + + init(bundle: Bundle = .main) { + self.transport = Self.readEnum( + bundle: bundle, + key: "OpenClawPushTransport", + fallback: .direct) + self.distribution = Self.readEnum( + bundle: bundle, + key: "OpenClawPushDistribution", + fallback: .local) + self.apnsEnvironment = Self.readEnum( + bundle: bundle, + key: "OpenClawPushAPNsEnvironment", + fallback: Self.defaultAPNsEnvironment) + self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL") + } + + var usesRelay: Bool { + self.transport == .relay + } + + private static func readURL(bundle: Bundle, key: String) -> URL? { + guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + guard let components = URLComponents(string: trimmed), + components.scheme?.lowercased() == "https", + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil + else { + return nil + } + return components.url + } + + private static func readEnum( + bundle: Bundle, + key: String, + fallback: T) + -> T where T.RawValue == String { + guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return T(rawValue: trimmed) ?? fallback + } + + private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox +} diff --git a/apps/ios/Sources/Push/PushRegistrationManager.swift b/apps/ios/Sources/Push/PushRegistrationManager.swift new file mode 100644 index 00000000000..77f54f8d108 --- /dev/null +++ b/apps/ios/Sources/Push/PushRegistrationManager.swift @@ -0,0 +1,169 @@ +import CryptoKit +import Foundation + +private struct DirectGatewayPushRegistrationPayload: Encodable { + var transport: String = PushTransportMode.direct.rawValue + var token: String + var topic: String + var environment: String +} + +private struct RelayGatewayPushRegistrationPayload: Encodable { + var transport: String = PushTransportMode.relay.rawValue + var relayHandle: String + var sendGrant: String + var gatewayDeviceId: String + var installationId: String + var topic: String + var environment: String + var distribution: String + var tokenDebugSuffix: String? +} + +struct PushRelayGatewayIdentity: Codable { + var deviceId: String + var publicKey: String +} + +actor PushRegistrationManager { + private let buildConfig: PushBuildConfig + private let relayClient: PushRelayClient? + + var usesRelayTransport: Bool { + self.buildConfig.transport == .relay + } + + init(buildConfig: PushBuildConfig = .current) { + self.buildConfig = buildConfig + self.relayClient = buildConfig.relayBaseURL.map { PushRelayClient(baseURL: $0) } + } + + func makeGatewayRegistrationPayload( + apnsTokenHex: String, + topic: String, + gatewayIdentity: PushRelayGatewayIdentity?) + async throws -> String { + switch self.buildConfig.transport { + case .direct: + return try Self.encodePayload( + DirectGatewayPushRegistrationPayload( + token: apnsTokenHex, + topic: topic, + environment: self.buildConfig.apnsEnvironment.rawValue)) + case .relay: + guard let gatewayIdentity else { + throw PushRelayError.relayMisconfigured("Missing gateway identity for relay registration") + } + return try await self.makeRelayPayload( + apnsTokenHex: apnsTokenHex, + topic: topic, + gatewayIdentity: gatewayIdentity) + } + } + + private func makeRelayPayload( + apnsTokenHex: String, + topic: String, + gatewayIdentity: PushRelayGatewayIdentity) + async throws -> String { + guard self.buildConfig.distribution == .official else { + throw PushRelayError.relayMisconfigured( + "Relay transport requires OpenClawPushDistribution=official") + } + guard self.buildConfig.apnsEnvironment == .production else { + throw PushRelayError.relayMisconfigured( + "Relay transport requires OpenClawPushAPNsEnvironment=production") + } + guard let relayClient = self.relayClient else { + throw PushRelayError.relayBaseURLMissing + } + guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines), + !bundleId.isEmpty + else { + throw PushRelayError.relayMisconfigured("Missing bundle identifier for relay registration") + } + guard let installationId = GatewaySettingsStore.loadStableInstanceID()? + .trimmingCharacters(in: .whitespacesAndNewlines), + !installationId.isEmpty + else { + throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration") + } + + let tokenHashHex = Self.sha256Hex(apnsTokenHex) + let relayOrigin = relayClient.normalizedBaseURLString + if let stored = PushRelayRegistrationStore.loadRegistrationState(), + stored.installationId == installationId, + stored.gatewayDeviceId == gatewayIdentity.deviceId, + stored.relayOrigin == relayOrigin, + stored.lastAPNsTokenHashHex == tokenHashHex, + !Self.isExpired(stored.relayHandleExpiresAtMs) + { + return try Self.encodePayload( + RelayGatewayPushRegistrationPayload( + relayHandle: stored.relayHandle, + sendGrant: stored.sendGrant, + gatewayDeviceId: gatewayIdentity.deviceId, + installationId: installationId, + topic: topic, + environment: self.buildConfig.apnsEnvironment.rawValue, + distribution: self.buildConfig.distribution.rawValue, + tokenDebugSuffix: stored.tokenDebugSuffix)) + } + + let response = try await relayClient.register( + installationId: installationId, + bundleId: bundleId, + appVersion: DeviceInfoHelper.appVersion(), + environment: self.buildConfig.apnsEnvironment, + distribution: self.buildConfig.distribution, + apnsTokenHex: apnsTokenHex, + gatewayIdentity: gatewayIdentity) + let registrationState = PushRelayRegistrationStore.RegistrationState( + relayHandle: response.relayHandle, + sendGrant: response.sendGrant, + relayOrigin: relayOrigin, + gatewayDeviceId: gatewayIdentity.deviceId, + relayHandleExpiresAtMs: response.expiresAtMs, + tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix), + lastAPNsTokenHashHex: tokenHashHex, + installationId: installationId, + lastTransport: self.buildConfig.transport.rawValue) + _ = PushRelayRegistrationStore.saveRegistrationState(registrationState) + return try Self.encodePayload( + RelayGatewayPushRegistrationPayload( + relayHandle: response.relayHandle, + sendGrant: response.sendGrant, + gatewayDeviceId: gatewayIdentity.deviceId, + installationId: installationId, + topic: topic, + environment: self.buildConfig.apnsEnvironment.rawValue, + distribution: self.buildConfig.distribution.rawValue, + tokenDebugSuffix: registrationState.tokenDebugSuffix)) + } + + private static func isExpired(_ expiresAtMs: Int64?) -> Bool { + guard let expiresAtMs else { return true } + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + // Refresh shortly before expiry so reconnect-path republishes a live handle. + return expiresAtMs <= nowMs + 60_000 + } + + private static func sha256Hex(_ value: String) -> String { + let digest = SHA256.hash(data: Data(value.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + private static func normalizeTokenSuffix(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + private static func encodePayload(_ payload: some Encodable) throws -> String { + let data = try JSONEncoder().encode(payload) + guard let json = String(data: data, encoding: .utf8) else { + throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8") + } + return json + } +} diff --git a/apps/ios/Sources/Push/PushRelayClient.swift b/apps/ios/Sources/Push/PushRelayClient.swift new file mode 100644 index 00000000000..07bb5caa3b7 --- /dev/null +++ b/apps/ios/Sources/Push/PushRelayClient.swift @@ -0,0 +1,349 @@ +import CryptoKit +import DeviceCheck +import Foundation +import StoreKit + +enum PushRelayError: LocalizedError { + case relayBaseURLMissing + case relayMisconfigured(String) + case invalidResponse(String) + case requestFailed(status: Int, message: String) + case unsupportedAppAttest + case missingReceipt + + var errorDescription: String? { + switch self { + case .relayBaseURLMissing: + "Push relay base URL missing" + case let .relayMisconfigured(message): + message + case let .invalidResponse(message): + message + case let .requestFailed(status, message): + "Push relay request failed (\(status)): \(message)" + case .unsupportedAppAttest: + "App Attest unavailable on this device" + case .missingReceipt: + "App Store receipt missing after refresh" + } + } +} + +private struct PushRelayChallengeResponse: Decodable { + var challengeId: String + var challenge: String + var expiresAtMs: Int64 +} + +private struct PushRelayRegisterSignedPayload: Encodable { + var challengeId: String + var installationId: String + var bundleId: String + var environment: String + var distribution: String + var gateway: PushRelayGatewayIdentity + var appVersion: String + var apnsToken: String +} + +private struct PushRelayAppAttestPayload: Encodable { + var keyId: String + var attestationObject: String? + var assertion: String + var clientDataHash: String + var signedPayloadBase64: String +} + +private struct PushRelayReceiptPayload: Encodable { + var base64: String +} + +private struct PushRelayRegisterRequest: Encodable { + var challengeId: String + var installationId: String + var bundleId: String + var environment: String + var distribution: String + var gateway: PushRelayGatewayIdentity + var appVersion: String + var apnsToken: String + var appAttest: PushRelayAppAttestPayload + var receipt: PushRelayReceiptPayload +} + +struct PushRelayRegisterResponse: Decodable { + var relayHandle: String + var sendGrant: String + var expiresAtMs: Int64? + var tokenSuffix: String? + var status: String +} + +private struct RelayErrorResponse: Decodable { + var error: String? + var message: String? + var reason: String? +} + +private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate { + private var continuation: CheckedContinuation? + private var activeRequest: SKReceiptRefreshRequest? + + func refresh() async throws { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + let request = SKReceiptRefreshRequest() + self.activeRequest = request + request.delegate = self + request.start() + } + } + + func requestDidFinish(_ request: SKRequest) { + self.continuation?.resume(returning: ()) + self.continuation = nil + self.activeRequest = nil + } + + func request(_ request: SKRequest, didFailWithError error: Error) { + self.continuation?.resume(throwing: error) + self.continuation = nil + self.activeRequest = nil + } +} + +private struct PushRelayAppAttestProof { + var keyId: String + var attestationObject: String? + var assertion: String + var clientDataHash: String + var signedPayloadBase64: String +} + +private final class PushRelayAppAttestService { + func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof { + let service = DCAppAttestService.shared + guard service.isSupported else { + throw PushRelayError.unsupportedAppAttest + } + + let keyID = try await self.loadOrCreateKeyID(using: service) + let attestationObject = try await self.attestKeyIfNeeded( + service: service, + keyID: keyID, + challenge: challenge) + let signedPayloadHash = Data(SHA256.hash(data: signedPayload)) + let assertion = try await self.generateAssertion( + service: service, + keyID: keyID, + signedPayloadHash: signedPayloadHash) + + return PushRelayAppAttestProof( + keyId: keyID, + attestationObject: attestationObject, + assertion: assertion.base64EncodedString(), + clientDataHash: Self.base64URL(signedPayloadHash), + signedPayloadBase64: signedPayload.base64EncodedString()) + } + + private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String { + if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty { + return existing + } + let keyID = try await service.generateKey() + _ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID) + return keyID + } + + private func attestKeyIfNeeded( + service: DCAppAttestService, + keyID: String, + challenge: String) + async throws -> String? { + if PushRelayRegistrationStore.loadAttestedKeyID() == keyID { + return nil + } + let challengeData = Data(challenge.utf8) + let clientDataHash = Data(SHA256.hash(data: challengeData)) + let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash) + // Apple treats App Attest key attestation as a one-time operation. Save the + // attested marker immediately so later receipt/network failures do not cause a + // permanently broken re-attestation loop on the same key. + _ = PushRelayRegistrationStore.saveAttestedKeyID(keyID) + return attestation.base64EncodedString() + } + + private func generateAssertion( + service: DCAppAttestService, + keyID: String, + signedPayloadHash: Data) + async throws -> Data { + do { + return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash) + } catch { + _ = PushRelayRegistrationStore.clearAppAttestKeyID() + _ = PushRelayRegistrationStore.clearAttestedKeyID() + throw error + } + } + + private static func base64URL(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +private final class PushRelayReceiptProvider { + func loadReceiptBase64() async throws -> String { + if let receipt = self.readReceiptData() { + return receipt.base64EncodedString() + } + let refreshCoordinator = PushRelayReceiptRefreshCoordinator() + try await refreshCoordinator.refresh() + if let refreshed = self.readReceiptData() { + return refreshed.base64EncodedString() + } + throw PushRelayError.missingReceipt + } + + private func readReceiptData() -> Data? { + guard let url = Bundle.main.appStoreReceiptURL else { return nil } + guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil } + return data + } +} + +// The client is constructed once and used behind PushRegistrationManager actor isolation. +final class PushRelayClient: @unchecked Sendable { + private let baseURL: URL + private let session: URLSession + private let jsonDecoder = JSONDecoder() + private let jsonEncoder = JSONEncoder() + private let appAttest = PushRelayAppAttestService() + private let receiptProvider = PushRelayReceiptProvider() + + init(baseURL: URL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + var normalizedBaseURLString: String { + Self.normalizeBaseURLString(self.baseURL) + } + + func register( + installationId: String, + bundleId: String, + appVersion: String, + environment: PushAPNsEnvironment, + distribution: PushDistributionMode, + apnsTokenHex: String, + gatewayIdentity: PushRelayGatewayIdentity) + async throws -> PushRelayRegisterResponse { + let challenge = try await self.fetchChallenge() + let signedPayload = PushRelayRegisterSignedPayload( + challengeId: challenge.challengeId, + installationId: installationId, + bundleId: bundleId, + environment: environment.rawValue, + distribution: distribution.rawValue, + gateway: gatewayIdentity, + appVersion: appVersion, + apnsToken: apnsTokenHex) + let signedPayloadData = try self.jsonEncoder.encode(signedPayload) + let appAttest = try await self.appAttest.createProof( + challenge: challenge.challenge, + signedPayload: signedPayloadData) + let receiptBase64 = try await self.receiptProvider.loadReceiptBase64() + let requestBody = PushRelayRegisterRequest( + challengeId: signedPayload.challengeId, + installationId: signedPayload.installationId, + bundleId: signedPayload.bundleId, + environment: signedPayload.environment, + distribution: signedPayload.distribution, + gateway: signedPayload.gateway, + appVersion: signedPayload.appVersion, + apnsToken: signedPayload.apnsToken, + appAttest: PushRelayAppAttestPayload( + keyId: appAttest.keyId, + attestationObject: appAttest.attestationObject, + assertion: appAttest.assertion, + clientDataHash: appAttest.clientDataHash, + signedPayloadBase64: appAttest.signedPayloadBase64), + receipt: PushRelayReceiptPayload(base64: receiptBase64)) + + let endpoint = self.baseURL.appending(path: "v1/push/register") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.timeoutInterval = 20 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try self.jsonEncoder.encode(requestBody) + + let (data, response) = try await self.session.data(for: request) + let status = Self.statusCode(from: response) + guard (200..<300).contains(status) else { + if status == 401 { + // If the relay rejects registration, drop local App Attest state so the next + // attempt re-attests instead of getting stuck without an attestation object. + _ = PushRelayRegistrationStore.clearAppAttestKeyID() + _ = PushRelayRegistrationStore.clearAttestedKeyID() + } + throw PushRelayError.requestFailed( + status: status, + message: Self.decodeErrorMessage(data: data)) + } + let decoded = try self.decode(PushRelayRegisterResponse.self, from: data) + return decoded + } + + private func fetchChallenge() async throws -> PushRelayChallengeResponse { + let endpoint = self.baseURL.appending(path: "v1/push/challenge") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.timeoutInterval = 10 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = Data("{}".utf8) + + let (data, response) = try await self.session.data(for: request) + let status = Self.statusCode(from: response) + guard (200..<300).contains(status) else { + throw PushRelayError.requestFailed( + status: status, + message: Self.decodeErrorMessage(data: data)) + } + return try self.decode(PushRelayChallengeResponse.self, from: data) + } + + private func decode(_ type: T.Type, from data: Data) throws -> T { + do { + return try self.jsonDecoder.decode(type, from: data) + } catch { + throw PushRelayError.invalidResponse(error.localizedDescription) + } + } + + private static func statusCode(from response: URLResponse) -> Int { + (response as? HTTPURLResponse)?.statusCode ?? 0 + } + + private static func normalizeBaseURLString(_ url: URL) -> String { + var absolute = url.absoluteString + while absolute.hasSuffix("/") { + absolute.removeLast() + } + return absolute + } + + private static func decodeErrorMessage(data: Data) -> String { + if let decoded = try? JSONDecoder().decode(RelayErrorResponse.self, from: data) { + let message = decoded.message ?? decoded.reason ?? decoded.error ?? "" + if !message.isEmpty { + return message + } + } + let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return raw.isEmpty ? "unknown relay error" : raw + } +} diff --git a/apps/ios/Sources/Push/PushRelayKeychainStore.swift b/apps/ios/Sources/Push/PushRelayKeychainStore.swift new file mode 100644 index 00000000000..4d7df09cd14 --- /dev/null +++ b/apps/ios/Sources/Push/PushRelayKeychainStore.swift @@ -0,0 +1,112 @@ +import Foundation + +private struct StoredPushRelayRegistrationState: Codable { + var relayHandle: String + var sendGrant: String + var relayOrigin: String? + var gatewayDeviceId: String + var relayHandleExpiresAtMs: Int64? + var tokenDebugSuffix: String? + var lastAPNsTokenHashHex: String + var installationId: String + var lastTransport: String +} + +enum PushRelayRegistrationStore { + private static let service = "ai.openclaw.pushrelay" + private static let registrationStateAccount = "registration-state" + private static let appAttestKeyIDAccount = "app-attest-key-id" + private static let appAttestedKeyIDAccount = "app-attested-key-id" + + struct RegistrationState: Codable { + var relayHandle: String + var sendGrant: String + var relayOrigin: String? + var gatewayDeviceId: String + var relayHandleExpiresAtMs: Int64? + var tokenDebugSuffix: String? + var lastAPNsTokenHashHex: String + var installationId: String + var lastTransport: String + } + + static func loadRegistrationState() -> RegistrationState? { + guard let raw = KeychainStore.loadString( + service: self.service, + account: self.registrationStateAccount), + let data = raw.data(using: .utf8), + let decoded = try? JSONDecoder().decode(StoredPushRelayRegistrationState.self, from: data) + else { + return nil + } + return RegistrationState( + relayHandle: decoded.relayHandle, + sendGrant: decoded.sendGrant, + relayOrigin: decoded.relayOrigin, + gatewayDeviceId: decoded.gatewayDeviceId, + relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs, + tokenDebugSuffix: decoded.tokenDebugSuffix, + lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex, + installationId: decoded.installationId, + lastTransport: decoded.lastTransport) + } + + @discardableResult + static func saveRegistrationState(_ state: RegistrationState) -> Bool { + let stored = StoredPushRelayRegistrationState( + relayHandle: state.relayHandle, + sendGrant: state.sendGrant, + relayOrigin: state.relayOrigin, + gatewayDeviceId: state.gatewayDeviceId, + relayHandleExpiresAtMs: state.relayHandleExpiresAtMs, + tokenDebugSuffix: state.tokenDebugSuffix, + lastAPNsTokenHashHex: state.lastAPNsTokenHashHex, + installationId: state.installationId, + lastTransport: state.lastTransport) + guard let data = try? JSONEncoder().encode(stored), + let raw = String(data: data, encoding: .utf8) + else { + return false + } + return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount) + } + + @discardableResult + static func clearRegistrationState() -> Bool { + KeychainStore.delete(service: self.service, account: self.registrationStateAccount) + } + + static func loadAppAttestKeyID() -> String? { + let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if value?.isEmpty == false { return value } + return nil + } + + @discardableResult + static func saveAppAttestKeyID(_ keyID: String) -> Bool { + KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount) + } + + @discardableResult + static func clearAppAttestKeyID() -> Bool { + KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount) + } + + static func loadAttestedKeyID() -> String? { + let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)? + .trimmingCharacters(in: .whitespacesAndNewlines) + if value?.isEmpty == false { return value } + return nil + } + + @discardableResult + static func saveAttestedKeyID(_ keyID: String) -> Bool { + KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount) + } + + @discardableResult + static func clearAttestedKeyID() -> Bool { + KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount) + } +} diff --git a/apps/ios/Sources/Settings/SettingsTab.swift b/apps/ios/Sources/Settings/SettingsTab.swift index 7aa79fa24ca..6df8c1ec510 100644 --- a/apps/ios/Sources/Settings/SettingsTab.swift +++ b/apps/ios/Sources/Settings/SettingsTab.swift @@ -767,12 +767,22 @@ struct SettingsTab: View { } let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBootstrapToken = + payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId) + } if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) self.gatewayToken = trimmedToken if !trimmedInstanceId.isEmpty { GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId) } + } else if !trimmedBootstrapToken.isEmpty { + self.gatewayToken = "" + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId) + } } if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines) @@ -780,6 +790,11 @@ struct SettingsTab: View { if !trimmedInstanceId.isEmpty { GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId) } + } else if !trimmedBootstrapToken.isEmpty { + self.gatewayPassword = "" + if !trimmedInstanceId.isEmpty { + GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId) + } } return true @@ -993,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/DeepLinkParserTests.swift b/apps/ios/Tests/DeepLinkParserTests.swift index 7f24aa3e34e..bac3288add1 100644 --- a/apps/ios/Tests/DeepLinkParserTests.swift +++ b/apps/ios/Tests/DeepLinkParserTests.swift @@ -86,7 +86,13 @@ private func agentAction( string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")! #expect( DeepLinkParser.parse(url) == .gateway( - .init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def"))) + .init( + host: "openclaw.local", + port: 18789, + tls: true, + bootstrapToken: nil, + token: "abc", + password: "def"))) } @Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() { @@ -102,14 +108,15 @@ private func agentAction( } @Test func parseGatewaySetupCodeParsesBase64UrlPayload() { - let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"# + let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == .init( host: "gateway.example.com", port: 443, tls: true, - token: "tok", + bootstrapToken: "tok", + token: nil, password: "pw")) } @@ -118,38 +125,40 @@ private func agentAction( } @Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() { - let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"# + let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == .init( host: "gateway.example.com", port: 443, tls: true, - token: "tok", + bootstrapToken: "tok", + token: nil, password: nil)) } @Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() { - let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == nil) } @Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() { - let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == nil) } @Test func parseGatewaySetupCodeAllowsLoopbackWs() { - let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"# let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload)) #expect(link == .init( host: "127.0.0.1", port: 18789, tls: false, - token: "tok", + bootstrapToken: "tok", + token: nil, password: nil)) } } diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift index f49f242ff24..42526dd21c4 100644 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -26,5 +26,10 @@ import Testing _ = try await transport.requestHealth(timeoutMs: 250) Issue.record("Expected requestHealth to throw when gateway not connected") } catch {} + + do { + try await transport.resetSession(sessionKey: "node-test") + Issue.record("Expected resetSession to throw when gateway not connected") + } catch {} } } 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 e7b286b4dd5..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.11 or 2026.3.11-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/ios/project.yml b/apps/ios/project.yml index 91b2a8e46d1..53e6489a25b 100644 --- a/apps/ios/project.yml +++ b/apps/ios/project.yml @@ -98,6 +98,17 @@ targets: SUPPORTS_LIVE_ACTIVITIES: YES ENABLE_APPINTENTS_METADATA: NO ENABLE_APP_INTENTS_METADATA_GENERATION: NO + configs: + Debug: + OPENCLAW_PUSH_TRANSPORT: direct + OPENCLAW_PUSH_DISTRIBUTION: local + OPENCLAW_PUSH_RELAY_BASE_URL: "" + OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox + Release: + OPENCLAW_PUSH_TRANSPORT: direct + OPENCLAW_PUSH_DISTRIBUTION: local + OPENCLAW_PUSH_RELAY_BASE_URL: "" + OPENCLAW_PUSH_APNS_ENVIRONMENT: production info: path: Sources/Info.plist properties: @@ -131,6 +142,10 @@ targets: NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake. NSSupportsLiveActivities: true ITSAppUsesNonExemptEncryption: false + OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)" + OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)" + OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)" + OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)" UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index c4472f8f452..607aab47940 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -324,6 +324,8 @@ final class ControlChannel { switch source { case .deviceToken: return "Auth: device token (paired device)" + case .bootstrapToken: + return "Auth: bootstrap token (setup code)" case .sharedToken: return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))" case .password: diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift index 26b64ea7c65..41b98111b4e 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor+Helpers.swift @@ -16,7 +16,14 @@ extension CronJobEditor { self.agentId = job.agentId ?? "" self.enabled = job.enabled self.deleteAfterRun = job.deleteAfterRun ?? false - self.sessionTarget = job.sessionTarget + switch job.parsedSessionTarget { + case .predefined(let target): + self.sessionTarget = target + self.preservedSessionTargetRaw = nil + case .session(let id): + self.sessionTarget = .isolated + self.preservedSessionTargetRaw = "session:\(id)" + } self.wakeMode = job.wakeMode switch job.schedule { @@ -51,7 +58,7 @@ extension CronJobEditor { self.channel = trimmed.isEmpty ? "last" : trimmed self.to = delivery.to ?? "" self.bestEffortDeliver = delivery.bestEffort ?? false - } else if self.sessionTarget == .isolated { + } else if self.isIsolatedLikeSessionTarget { self.deliveryMode = .announce } } @@ -80,7 +87,7 @@ extension CronJobEditor { "name": name, "enabled": self.enabled, "schedule": schedule, - "sessionTarget": self.sessionTarget.rawValue, + "sessionTarget": self.effectiveSessionTargetRaw, "wakeMode": self.wakeMode.rawValue, "payload": payload, ] @@ -92,7 +99,7 @@ extension CronJobEditor { root["agentId"] = NSNull() } - if self.sessionTarget == .isolated { + if self.isIsolatedLikeSessionTarget { root["delivery"] = self.buildDelivery() } @@ -160,7 +167,7 @@ extension CronJobEditor { } func buildSelectedPayload() throws -> [String: Any] { - if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } + if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() } switch self.payloadKind { case .systemEvent: let text = self.trimmed(self.systemEventText) @@ -171,7 +178,7 @@ extension CronJobEditor { } func validateSessionTarget(_ payload: [String: Any]) throws { - if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { + if self.effectiveSessionTargetRaw == "main", payload["kind"] as? String == "agentTurn" { throw NSError( domain: "Cron", code: 0, @@ -181,7 +188,7 @@ extension CronJobEditor { ]) } - if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { + if self.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" { throw NSError( domain: "Cron", code: 0, @@ -257,6 +264,17 @@ extension CronJobEditor { return Int(floor(n * factor)) } + var effectiveSessionTargetRaw: String { + if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty { + return preserved + } + return self.sessionTarget.rawValue + } + + var isIsolatedLikeSessionTarget: Bool { + self.effectiveSessionTargetRaw != "main" + } + func formatDuration(ms: Int) -> String { DurationFormattingSupport.conciseDuration(ms: ms) } diff --git a/apps/macos/Sources/OpenClaw/CronJobEditor.swift b/apps/macos/Sources/OpenClaw/CronJobEditor.swift index a7d88a4f2fb..292f3a63284 100644 --- a/apps/macos/Sources/OpenClaw/CronJobEditor.swift +++ b/apps/macos/Sources/OpenClaw/CronJobEditor.swift @@ -16,7 +16,7 @@ struct CronJobEditor: View { + "Use an isolated session for agent turns so your main chat stays clean." static let sessionTargetNote = "Main jobs post a system event into the current main session. " - + "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel." + + "Current and isolated-style jobs run agent turns and can announce results to a channel." static let scheduleKindNote = "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." static let isolatedPayloadNote = @@ -29,6 +29,7 @@ struct CronJobEditor: View { @State var agentId: String = "" @State var enabled: Bool = true @State var sessionTarget: CronSessionTarget = .main + @State var preservedSessionTargetRaw: String? @State var wakeMode: CronWakeMode = .now @State var deleteAfterRun: Bool = false @@ -117,6 +118,7 @@ struct CronJobEditor: View { Picker("", selection: self.$sessionTarget) { Text("main").tag(CronSessionTarget.main) Text("isolated").tag(CronSessionTarget.isolated) + Text("current").tag(CronSessionTarget.current) } .labelsHidden() .pickerStyle(.segmented) @@ -209,7 +211,7 @@ struct CronJobEditor: View { GroupBox("Payload") { VStack(alignment: .leading, spacing: 10) { - if self.sessionTarget == .isolated { + if self.isIsolatedLikeSessionTarget { Text(Self.isolatedPayloadNote) .font(.footnote) .foregroundStyle(.secondary) @@ -289,8 +291,11 @@ struct CronJobEditor: View { self.sessionTarget = .isolated } } - .onChange(of: self.sessionTarget) { _, newValue in - if newValue == .isolated { + .onChange(of: self.sessionTarget) { oldValue, newValue in + if oldValue != newValue { + self.preservedSessionTargetRaw = nil + } + if newValue != .main { self.payloadKind = .agentTurn } else if newValue == .main, self.payloadKind == .agentTurn { self.payloadKind = .systemEvent diff --git a/apps/macos/Sources/OpenClaw/CronModels.swift b/apps/macos/Sources/OpenClaw/CronModels.swift index e0ce46c13da..40079453974 100644 --- a/apps/macos/Sources/OpenClaw/CronModels.swift +++ b/apps/macos/Sources/OpenClaw/CronModels.swift @@ -3,12 +3,39 @@ import Foundation enum CronSessionTarget: String, CaseIterable, Identifiable, Codable { case main case isolated + case current var id: String { self.rawValue } } +enum CronCustomSessionTarget: Codable, Equatable { + case predefined(CronSessionTarget) + case session(id: String) + + var rawValue: String { + switch self { + case .predefined(let target): + return target.rawValue + case .session(let id): + return "session:\(id)" + } + } + + static func from(_ value: String) -> CronCustomSessionTarget { + if let predefined = CronSessionTarget(rawValue: value) { + return .predefined(predefined) + } + if value.hasPrefix("session:") { + let sessionId = String(value.dropFirst(8)) + return .session(id: sessionId) + } + // Fallback to isolated for unknown values + return .predefined(.isolated) + } +} + enum CronWakeMode: String, CaseIterable, Identifiable, Codable { case now case nextHeartbeat = "next-heartbeat" @@ -204,12 +231,69 @@ struct CronJob: Identifiable, Codable, Equatable { let createdAtMs: Int let updatedAtMs: Int let schedule: CronSchedule - let sessionTarget: CronSessionTarget + private let sessionTargetRaw: String let wakeMode: CronWakeMode let payload: CronPayload let delivery: CronDelivery? let state: CronJobState + enum CodingKeys: String, CodingKey { + case id + case agentId + case name + case description + case enabled + case deleteAfterRun + case createdAtMs + case updatedAtMs + case schedule + case sessionTargetRaw = "sessionTarget" + case wakeMode + case payload + case delivery + case state + } + + /// Parsed session target (predefined or custom session ID) + var parsedSessionTarget: CronCustomSessionTarget { + CronCustomSessionTarget.from(self.sessionTargetRaw) + } + + /// Compatibility shim for existing editor/UI code paths that still use the + /// predefined enum. + var sessionTarget: CronSessionTarget { + switch self.parsedSessionTarget { + case .predefined(let target): + return target + case .session: + return .isolated + } + } + + var sessionTargetDisplayValue: String { + self.parsedSessionTarget.rawValue + } + + var transcriptSessionKey: String? { + switch self.parsedSessionTarget { + case .predefined(.main): + return nil + case .predefined(.isolated), .predefined(.current): + return "cron:\(self.id)" + case .session(let id): + return id + } + } + + var supportsAnnounceDelivery: Bool { + switch self.parsedSessionTarget { + case .predefined(.main): + return false + case .predefined(.isolated), .predefined(.current), .session: + return true + } + } + var displayName: String { let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? "Untitled job" : trimmed diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift index 69655bdc302..85e45928853 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -18,7 +18,7 @@ extension CronSettings { } } HStack(spacing: 6) { - StatusPill(text: job.sessionTarget.rawValue, tint: .secondary) + StatusPill(text: job.sessionTargetDisplayValue, tint: .secondary) StatusPill(text: job.wakeMode.rawValue, tint: .secondary) if let agentId = job.agentId, !agentId.isEmpty { StatusPill(text: "agent \(agentId)", tint: .secondary) @@ -34,9 +34,9 @@ extension CronSettings { @ViewBuilder func jobContextMenu(_ job: CronJob) -> some View { Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } } - if job.sessionTarget == .isolated { + if let transcriptSessionKey = job.transcriptSessionKey { Button("Open transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + WebChatManager.shared.show(sessionKey: transcriptSessionKey) } } Divider() @@ -75,9 +75,9 @@ extension CronSettings { .labelsHidden() Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } } .buttonStyle(.borderedProminent) - if job.sessionTarget == .isolated { + if let transcriptSessionKey = job.transcriptSessionKey { Button("Transcript") { - WebChatManager.shared.show(sessionKey: "cron:\(job.id)") + WebChatManager.shared.show(sessionKey: transcriptSessionKey) } .buttonStyle(.bordered) } @@ -103,7 +103,7 @@ extension CronSettings { if let agentId = job.agentId, !agentId.isEmpty { LabeledContent("Agent") { Text(agentId) } } - LabeledContent("Session") { Text(job.sessionTarget.rawValue) } + LabeledContent("Session") { Text(job.sessionTargetDisplayValue) } LabeledContent("Wake") { Text(job.wakeMode.rawValue) } LabeledContent("Next run") { if let date = job.nextRunDate { @@ -224,7 +224,7 @@ extension CronSettings { HStack(spacing: 8) { if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) } if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) } - if job.sessionTarget == .isolated { + if job.supportsAnnounceDelivery { let delivery = job.delivery if let delivery { if delivery.mode == .announce { diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift index c7d9d0928e1..a36e58db1d8 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -45,8 +45,8 @@ enum ExecApprovalEvaluator { let skillAllow: Bool if approvals.agent.autoAllowSkills, !allowlistResolutions.isEmpty { - let bins = await SkillBinsCache.shared.currentBins() - skillAllow = allowlistResolutions.allSatisfy { bins.contains($0.executableName) } + let bins = await SkillBinsCache.shared.currentTrust() + skillAllow = self.isSkillAutoAllowed(allowlistResolutions, trustedBinsByName: bins) } else { skillAllow = false } @@ -65,4 +65,26 @@ enum ExecApprovalEvaluator { allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil, skillAllow: skillAllow) } + + static func isSkillAutoAllowed( + _ resolutions: [ExecCommandResolution], + trustedBinsByName: [String: Set]) -> Bool + { + guard !resolutions.isEmpty, !trustedBinsByName.isEmpty else { return false } + return resolutions.allSatisfy { resolution in + guard let executableName = SkillBinsCache.normalizeSkillBinName(resolution.executableName), + let resolvedPath = SkillBinsCache.normalizeResolvedPath(resolution.resolvedPath) + else { + return false + } + return trustedBinsByName[executableName]?.contains(resolvedPath) == true + } + } + + static func _testIsSkillAutoAllowed( + _ resolutions: [ExecCommandResolution], + trustedBinsByName: [String: Set]) -> Bool + { + self.isSkillAutoAllowed(resolutions, trustedBinsByName: trustedBinsByName) + } } diff --git a/apps/macos/Sources/OpenClaw/ExecApprovals.swift b/apps/macos/Sources/OpenClaw/ExecApprovals.swift index ba49b37cd9f..141da33ad48 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovals.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovals.swift @@ -370,6 +370,17 @@ enum ExecApprovalsStore { static func resolve(agentId: String?) -> ExecApprovalsResolved { let file = self.ensureFile() + return self.resolveFromFile(file, agentId: agentId) + } + + /// Read-only resolve: loads file without writing (no ensureFile side effects). + /// Safe to call from background threads / off MainActor. + static func resolveReadOnly(agentId: String?) -> ExecApprovalsResolved { + let file = self.loadFile() + return self.resolveFromFile(file, agentId: agentId) + } + + private static func resolveFromFile(_ file: ExecApprovalsFile, agentId: String?) -> ExecApprovalsResolved { let defaults = file.defaults ?? ExecApprovalsDefaults() let resolvedDefaults = ExecApprovalsResolvedDefaults( security: defaults.security ?? self.defaultSecurity, @@ -777,6 +788,7 @@ actor SkillBinsCache { static let shared = SkillBinsCache() private var bins: Set = [] + private var trustByName: [String: Set] = [:] private var lastRefresh: Date? private let refreshInterval: TimeInterval = 90 @@ -787,27 +799,90 @@ actor SkillBinsCache { return self.bins } + func currentTrust(force: Bool = false) async -> [String: Set] { + if force || self.isStale() { + await self.refresh() + } + return self.trustByName + } + func refresh() async { do { let report = try await GatewayConnection.shared.skillsStatus() - var next = Set() - for skill in report.skills { - for bin in skill.requirements.bins { - let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { next.insert(trimmed) } - } - } - self.bins = next + let trust = Self.buildTrustIndex(report: report, searchPaths: CommandResolver.preferredPaths()) + self.bins = trust.names + self.trustByName = trust.pathsByName self.lastRefresh = Date() } catch { if self.lastRefresh == nil { self.bins = [] + self.trustByName = [:] } } } + static func normalizeSkillBinName(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed.isEmpty ? nil : trimmed + } + + static func normalizeResolvedPath(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return nil } + return URL(fileURLWithPath: trimmed).standardizedFileURL.path + } + + static func buildTrustIndex( + report: SkillsStatusReport, + searchPaths: [String]) -> SkillBinTrustIndex + { + var names = Set() + var pathsByName: [String: Set] = [:] + + for skill in report.skills { + for bin in skill.requirements.bins { + let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + names.insert(trimmed) + + guard let name = self.normalizeSkillBinName(trimmed), + let resolvedPath = self.resolveSkillBinPath(trimmed, searchPaths: searchPaths), + let normalizedPath = self.normalizeResolvedPath(resolvedPath) + else { + continue + } + + var paths = pathsByName[name] ?? Set() + paths.insert(normalizedPath) + pathsByName[name] = paths + } + } + + return SkillBinTrustIndex(names: names, pathsByName: pathsByName) + } + + private static func resolveSkillBinPath(_ bin: String, searchPaths: [String]) -> String? { + let expanded = bin.hasPrefix("~") ? (bin as NSString).expandingTildeInPath : bin + if expanded.contains("/") || expanded.contains("\\") { + return FileManager().isExecutableFile(atPath: expanded) ? expanded : nil + } + return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths) + } + private func isStale() -> Bool { guard let lastRefresh else { return true } return Date().timeIntervalSince(lastRefresh) > self.refreshInterval } + + static func _testBuildTrustIndex( + report: SkillsStatusReport, + searchPaths: [String]) -> SkillBinTrustIndex + { + self.buildTrustIndex(report: report, searchPaths: searchPaths) + } +} + +struct SkillBinTrustIndex { + let names: Set + let pathsByName: [String: Set] } diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift index 379e8c0f559..08e60b84d2b 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsGatewayPrompter.swift @@ -43,7 +43,33 @@ final class ExecApprovalsGatewayPrompter { do { let data = try JSONEncoder().encode(payload) let request = try JSONDecoder().decode(GatewayApprovalRequest.self, from: data) - guard self.shouldPresent(request: request) else { return } + let presentation = self.shouldPresent(request: request) + guard presentation.shouldAsk else { + // Ask policy says no prompt needed – resolve based on security policy + let decision: ExecApprovalDecision = presentation.security == .full ? .allowOnce : .deny + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10000) + return + } + guard presentation.canPresent else { + let decision = Self.fallbackDecision( + request: request.request, + askFallback: presentation.askFallback, + allowlist: presentation.allowlist) + try await GatewayConnection.shared.requestVoid( + method: .execApprovalResolve, + params: [ + "id": AnyCodable(request.id), + "decision": AnyCodable(decision.rawValue), + ], + timeoutMs: 10000) + return + } let decision = ExecApprovalsPromptPresenter.prompt(request.request) try await GatewayConnection.shared.requestVoid( method: .execApprovalResolve, @@ -57,16 +83,89 @@ final class ExecApprovalsGatewayPrompter { } } - private func shouldPresent(request: GatewayApprovalRequest) -> Bool { + /// Whether the ask policy requires prompting the user. + /// Note: this only determines if a prompt is shown, not whether the action is allowed. + /// The security policy (full/deny/allowlist) decides the actual outcome. + private static func shouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool { + switch ask { + case .always: + return true + case .onMiss: + return security == .allowlist + case .off: + return false + } + } + + struct PresentationDecision { + /// Whether the ask policy requires prompting the user (not whether the action is allowed). + var shouldAsk: Bool + /// Whether the prompt can actually be shown (session match, recent activity, etc.). + var canPresent: Bool + /// The resolved security policy, used to determine allow/deny when no prompt is shown. + var security: ExecSecurity + /// Fallback security policy when a prompt is needed but can't be presented. + var askFallback: ExecSecurity + var allowlist: [ExecAllowlistEntry] + } + + private func shouldPresent(request: GatewayApprovalRequest) -> PresentationDecision { let mode = AppStateStore.shared.connectionMode let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines) - return Self.shouldPresent( + + // Read-only resolve to avoid disk writes on the MainActor + let approvals = ExecApprovalsStore.resolveReadOnly(agentId: request.request.agentId) + let security = approvals.agent.security + let ask = approvals.agent.ask + + let shouldAsk = Self.shouldAsk(security: security, ask: ask) + + let canPresent = shouldAsk && Self.shouldPresent( mode: mode, activeSession: activeSession, requestSession: requestSession, lastInputSeconds: Self.lastInputSeconds(), thresholdSeconds: 120) + + return PresentationDecision( + shouldAsk: shouldAsk, + canPresent: canPresent, + security: security, + askFallback: approvals.agent.askFallback, + allowlist: approvals.allowlist) + } + + private static func fallbackDecision( + request: ExecApprovalPromptRequest, + askFallback: ExecSecurity, + allowlist: [ExecAllowlistEntry]) -> ExecApprovalDecision + { + guard askFallback == .allowlist else { + return askFallback == .full ? .allowOnce : .deny + } + let resolution = self.fallbackResolution(for: request) + let match = ExecAllowlistMatcher.match(entries: allowlist, resolution: resolution) + return match == nil ? .deny : .allowOnce + } + + private static func fallbackResolution(for request: ExecApprovalPromptRequest) -> ExecCommandResolution? { + let resolvedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedResolvedPath = (resolvedPath?.isEmpty == false) ? resolvedPath : nil + let rawExecutable = self.firstToken(from: request.command) ?? trimmedResolvedPath ?? "" + guard !rawExecutable.isEmpty || trimmedResolvedPath != nil else { return nil } + let executableName = trimmedResolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? rawExecutable + return ExecCommandResolution( + rawExecutable: rawExecutable, + resolvedPath: trimmedResolvedPath, + executableName: executableName, + cwd: request.cwd) + } + + private static func firstToken(from command: String) -> String? { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) } private static func shouldPresent( @@ -117,5 +216,29 @@ extension ExecApprovalsGatewayPrompter { lastInputSeconds: lastInputSeconds, thresholdSeconds: thresholdSeconds) } + + static func _testShouldAsk(security: ExecSecurity, ask: ExecAsk) -> Bool { + self.shouldAsk(security: security, ask: ask) + } + + static func _testFallbackDecision( + command: String, + resolvedPath: String?, + askFallback: ExecSecurity, + allowlistPatterns: [String]) -> ExecApprovalDecision + { + self.fallbackDecision( + request: ExecApprovalPromptRequest( + command: command, + cwd: nil, + host: nil, + security: nil, + ask: nil, + agentId: nil, + resolvedPath: resolvedPath, + sessionKey: nil), + askFallback: askFallback, + allowlist: allowlistPatterns.map { ExecAllowlistEntry(pattern: $0) }) + } } #endif diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index 91a22153f3c..f89293a81aa 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -37,8 +37,7 @@ struct ExecCommandResolution { var resolutions: [ExecCommandResolution] = [] resolutions.reserveCapacity(segments.count) for segment in segments { - guard let token = self.parseFirstToken(segment), - let resolution = self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env) + guard let resolution = self.resolveShellSegmentExecutable(segment, cwd: cwd, env: env) else { return [] } @@ -88,6 +87,20 @@ struct ExecCommandResolution { cwd: cwd) } + private static func resolveShellSegmentExecutable( + _ segment: String, + cwd: String?, + env: [String: String]?) -> ExecCommandResolution? + { + let tokens = self.tokenizeShellWords(segment) + guard !tokens.isEmpty else { return nil } + let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(tokens) + guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + return nil + } + return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) + } + private static func parseFirstToken(_ command: String) -> String? { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -102,6 +115,59 @@ struct ExecCommandResolution { return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init) } + private static func tokenizeShellWords(_ command: String) -> [String] { + let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + + var tokens: [String] = [] + var current = "" + var inSingle = false + var inDouble = false + var escaped = false + + func appendCurrent() { + guard !current.isEmpty else { return } + tokens.append(current) + current.removeAll(keepingCapacity: true) + } + + for ch in trimmed { + if escaped { + current.append(ch) + escaped = false + continue + } + + if ch == "\\", !inSingle { + escaped = true + continue + } + + if ch == "'", !inDouble { + inSingle.toggle() + continue + } + + if ch == "\"", !inSingle { + inDouble.toggle() + continue + } + + if ch.isWhitespace, !inSingle, !inDouble { + appendCurrent() + continue + } + + current.append(ch) + } + + if escaped { + current.append("\\") + } + appendCurrent() + return tokens + } + private enum ShellTokenContext { case unquoted case doubleQuoted @@ -148,8 +214,14 @@ struct ExecCommandResolution { while idx < chars.count { let ch = chars[idx] let next: Character? = idx + 1 < chars.count ? chars[idx + 1] : nil + let lookahead = self.nextShellSignificantCharacter(chars: chars, after: idx, inSingle: inSingle) if escaped { + if ch == "\n" { + escaped = false + idx += 1 + continue + } current.append(ch) escaped = false idx += 1 @@ -157,6 +229,10 @@ struct ExecCommandResolution { } if ch == "\\", !inSingle { + if next == "\n" { + idx += 2 + continue + } current.append(ch) escaped = true idx += 1 @@ -177,7 +253,7 @@ struct ExecCommandResolution { continue } - if !inSingle, self.shouldFailClosedForShell(ch: ch, next: next, inDouble: inDouble) { + if !inSingle, self.shouldFailClosedForShell(ch: ch, next: lookahead, inDouble: inDouble) { // Fail closed on command/process substitution in allowlist mode, // including command substitution inside double-quoted shell strings. return nil @@ -201,6 +277,25 @@ struct ExecCommandResolution { return segments } + private static func nextShellSignificantCharacter( + chars: [Character], + after idx: Int, + inSingle: Bool) -> Character? + { + guard !inSingle else { + return idx + 1 < chars.count ? chars[idx + 1] : nil + } + var cursor = idx + 1 + while cursor < chars.count { + if chars[cursor] == "\\", cursor + 1 < chars.count, chars[cursor + 1] == "\n" { + cursor += 2 + continue + } + return chars[cursor] + } + return nil + } + private static func shouldFailClosedForShell(ch: Character, next: Character?, inDouble: Bool) -> Bool { let context: ShellTokenContext = inDouble ? .doubleQuoted : .unquoted guard let rules = self.shellFailClosedRules[context] else { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index fa216d09c5f..5e093c49e24 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -77,6 +77,7 @@ final class MacNodeModeCoordinator { try await self.session.connect( url: config.url, token: config.token, + bootstrapToken: nil, password: config.password, connectOptions: connectOptions, sessionBox: sessionBox, diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 0beeb2bdc27..f35e4e4c4ec 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -508,6 +508,8 @@ extension OnboardingView { return ("exclamationmark.triangle.fill", .orange) case .gatewayTokenNotConfigured: return ("wrench.and.screwdriver.fill", .orange) + case .setupCodeExpired: + return ("qrcode.viewfinder", .orange) case .passwordRequired: return ("lock.slash.fill", .orange) case .pairingRequired: diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index dfae5c3bcaa..7d8837415ff 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -47,7 +47,7 @@ actor PortGuardian { let listeners = await self.listeners(on: port) guard !listeners.isEmpty else { continue } for listener in listeners { - if self.isExpected(listener, port: port, mode: mode) { + if Self.isExpected(listener, port: port, mode: mode) { let message = """ port \(port) already served by expected \(listener.command) (pid \(listener.pid)) — keeping @@ -55,6 +55,14 @@ actor PortGuardian { self.logger.info("\(message, privacy: .public)") continue } + if mode == .remote { + let message = """ + port \(port) held by \(listener.command) + (pid \(listener.pid)) in remote mode — not killing + """ + self.logger.warning(message) + continue + } let killed = await self.kill(listener.pid) if killed { let message = """ @@ -271,8 +279,8 @@ actor PortGuardian { switch mode { case .remote: - expectedDesc = "SSH tunnel to remote gateway" - okPredicate = { $0.command.lowercased().contains("ssh") } + expectedDesc = "Remote gateway (SSH tunnel, Docker, or direct)" + okPredicate = { _ in true } case .local: expectedDesc = "Gateway websocket (node/tsx)" okPredicate = { listener in @@ -352,13 +360,12 @@ actor PortGuardian { return sigkill.ok } - private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { + private static func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool { let cmd = listener.command.lowercased() let full = listener.fullCommand.lowercased() switch mode { case .remote: - // Remote mode expects an SSH tunnel for the gateway WebSocket port. - if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") } + if port == GatewayEnvironment.gatewayPort() { return true } return false case .local: // The gateway daemon may listen as `openclaw` or as its runtime (`node`, `bun`, etc). @@ -406,6 +413,16 @@ extension PortGuardian { self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) } } + static func _testIsExpected( + command: String, + fullCommand: String, + port: Int, + mode: AppState.ConnectionMode) -> Bool + { + let listener = Listener(pid: 0, command: command, fullCommand: fullCommand, user: nil) + return Self.isExpected(listener, port: port, mode: mode) + } + static func _testBuildReport( port: Int, mode: AppState.ConnectionMode, diff --git a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift index f878d0f5e28..7073ad81de7 100644 --- a/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift +++ b/apps/macos/Sources/OpenClaw/RemoteGatewayProbe.swift @@ -6,6 +6,7 @@ enum RemoteGatewayAuthIssue: Equatable { case tokenRequired case tokenMismatch case gatewayTokenNotConfigured + case setupCodeExpired case passwordRequired case pairingRequired @@ -20,6 +21,8 @@ enum RemoteGatewayAuthIssue: Equatable { self = .tokenMismatch case .authTokenNotConfigured: self = .gatewayTokenNotConfigured + case .authBootstrapTokenInvalid: + self = .setupCodeExpired case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured: self = .passwordRequired case .pairingRequired: @@ -33,7 +36,7 @@ enum RemoteGatewayAuthIssue: Equatable { switch self { case .tokenRequired, .tokenMismatch: true - case .gatewayTokenNotConfigured, .passwordRequired, .pairingRequired: + case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired: false } } @@ -46,6 +49,8 @@ enum RemoteGatewayAuthIssue: Equatable { "That token did not match the gateway" case .gatewayTokenNotConfigured: "This gateway host needs token setup" + case .setupCodeExpired: + "This setup code is no longer valid" case .passwordRequired: "This gateway is using unsupported auth" case .pairingRequired: @@ -61,6 +66,8 @@ enum RemoteGatewayAuthIssue: Equatable { "Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again." case .gatewayTokenNotConfigured: "This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway." + case .setupCodeExpired: + "Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again." case .passwordRequired: "This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry." case .pairingRequired: @@ -72,6 +79,8 @@ enum RemoteGatewayAuthIssue: Equatable { switch self { case .tokenRequired, .gatewayTokenNotConfigured: "No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`." + case .setupCodeExpired: + nil case .pairingRequired: "If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`." case .tokenMismatch, .passwordRequired: @@ -87,6 +96,8 @@ enum RemoteGatewayAuthIssue: Equatable { "Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host." case .gatewayTokenNotConfigured: "This gateway has token auth enabled, but no gateway.auth.token is configured on the host." + case .setupCodeExpired: + "Setup code expired or already used. Scan a fresh setup code, then try again." case .passwordRequired: "This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet." case .pairingRequired: @@ -108,6 +119,8 @@ struct RemoteGatewayProbeSuccess: Equatable { switch self.authSource { case .some(.deviceToken): "Connected via paired device" + case .some(.bootstrapToken): + "Connected with setup code" case .some(.sharedToken): "Connected with gateway token" case .some(.password): @@ -121,6 +134,8 @@ struct RemoteGatewayProbeSuccess: Equatable { switch self.authSource { case .some(.deviceToken): "This Mac used a stored device token. New or unpaired devices may still need the gateway token." + case .some(.bootstrapToken): + "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth." case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil: nil } diff --git a/apps/macos/Sources/OpenClaw/Resources/Info.plist b/apps/macos/Sources/OpenClaw/Resources/Info.plist index 0bfd45cc97b..89ebf70beb4 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.11 + 2026.3.14 CFBundleVersion - 202603110 + 202603140 CFBundleIconFile OpenClaw CFBundleURLTypes diff --git a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift index 3112f57879b..6f1ef2b723d 100644 --- a/apps/macos/Sources/OpenClaw/RuntimeLocator.swift +++ b/apps/macos/Sources/OpenClaw/RuntimeLocator.swift @@ -54,7 +54,7 @@ enum RuntimeResolutionError: Error { enum RuntimeLocator { private static let logger = Logger(subsystem: "ai.openclaw", category: "runtime") - private static let minNode = RuntimeVersion(major: 22, minor: 0, patch: 0) + private static let minNode = RuntimeVersion(major: 22, minor: 16, patch: 0) static func resolve( searchPaths: [String] = CommandResolver.preferredPaths()) -> Result @@ -91,7 +91,7 @@ enum RuntimeLocator { switch error { case let .notFound(searchPaths): [ - "openclaw needs Node >=22.0.0 but found no runtime.", + "openclaw needs Node >=22.16.0 but found no runtime.", "PATH searched: \(searchPaths.joined(separator: ":"))", "Install Node: https://nodejs.org/en/download", ].joined(separator: "\n") @@ -105,7 +105,7 @@ enum RuntimeLocator { [ "Could not parse \(kind.rawValue) version output \"\(raw)\" from \(path).", "PATH searched: \(searchPaths.joined(separator: ":"))", - "Try reinstalling or pinning a supported version (Node >=22.0.0).", + "Try reinstalling or pinning a supported version (Node >=22.16.0).", ].joined(separator: "\n") } } diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 9110ce59faf..86c225f9ef0 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -59,7 +59,23 @@ struct MacGatewayChatTransport: OpenClawChatTransport { method: "sessions.list", params: params, timeoutMs: 15000) - return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) + let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) + let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey() + let defaults = decoded.defaults.map { + OpenClawChatSessionsDefaults( + model: $0.model, + contextTokens: $0.contextTokens, + mainSessionKey: mainSessionKey) + } ?? OpenClawChatSessionsDefaults( + model: nil, + contextTokens: nil, + mainSessionKey: mainSessionKey) + return OpenClawChatSessionsListResponse( + ts: decoded.ts, + path: decoded.path, + count: decoded.count, + defaults: defaults, + sessions: decoded.sessions) } func setSessionModel(sessionKey: String, model: String?) async throws { @@ -103,6 +119,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport { try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) } + func resetSession(sessionKey: String) async throws { + _ = try await GatewayConnection.shared.request( + method: "sessions.reset", + params: ["key": AnyCodable(sessionKey)], + timeoutMs: 10000) + } + func events() -> AsyncStream { AsyncStream { continuation in let task = Task { diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index b743060f6c0..3003ae79f7b 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1106,6 +1106,7 @@ public struct PushTestResult: Codable, Sendable { public let tokensuffix: String public let topic: String public let environment: String + public let transport: String public init( ok: Bool, @@ -1114,7 +1115,8 @@ public struct PushTestResult: Codable, Sendable { reason: String?, tokensuffix: String, topic: String, - environment: String) + environment: String, + transport: String) { self.ok = ok self.status = status @@ -1123,6 +1125,7 @@ public struct PushTestResult: Codable, Sendable { self.tokensuffix = tokensuffix self.topic = topic self.environment = environment + self.transport = transport } private enum CodingKeys: String, CodingKey { @@ -1133,6 +1136,7 @@ public struct PushTestResult: Codable, Sendable { case tokensuffix = "tokenSuffix" case topic case environment + case transport } } @@ -1318,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let key: String public let label: AnyCodable? public let thinkinglevel: AnyCodable? + public let fastmode: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? public let responseusage: AnyCodable? @@ -1339,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable { key: String, label: AnyCodable?, thinkinglevel: AnyCodable?, + fastmode: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, responseusage: AnyCodable?, @@ -1359,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.key = key self.label = label self.thinkinglevel = thinkinglevel + self.fastmode = fastmode self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel self.responseusage = responseusage @@ -1381,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable { case key case label case thinkinglevel = "thinkingLevel" + case fastmode = "fastMode" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" case responseusage = "responseUsage" diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index f12b8f717dc..fa92cc81ef5 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -141,6 +141,26 @@ struct ExecAllowlistTests { #expect(resolutions.isEmpty) } + @Test func `resolve for allowlist fails closed on line-continued command substitution`() { + let command = ["/bin/sh", "-lc", "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-line-cont-subst)", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + + @Test func `resolve for allowlist fails closed on chained line-continued command substitution`() { + let command = ["/bin/sh", "-lc", "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "echo ok && $\\\n(/usr/bin/touch /tmp/openclaw-allowlist-test-chained-line-cont-subst)", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.isEmpty) + } + @Test func `resolve for allowlist fails closed on quoted backticks`() { let command = ["/bin/sh", "-lc", "echo \"ok `/usr/bin/id`\""] let resolutions = ExecCommandResolution.resolveForAllowlist( @@ -208,6 +228,30 @@ struct ExecAllowlistTests { #expect(resolutions[1].executableName == "touch") } + @Test func `resolve for allowlist unwraps env dispatch wrappers inside shell segments`() { + let command = ["/bin/sh", "-lc", "env /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "env /usr/bin/touch /tmp/openclaw-allowlist-test", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/touch") + #expect(resolutions[0].executableName == "touch") + } + + @Test func `resolve for allowlist unwraps env assignments inside shell segments`() { + let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"] + let resolutions = ExecCommandResolution.resolveForAllowlist( + command: command, + rawCommand: "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test", + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + #expect(resolutions.count == 1) + #expect(resolutions[0].resolvedPath == "/usr/bin/touch") + #expect(resolutions[0].executableName == "touch") + } + @Test func `resolve for allowlist unwraps env to effective direct executable`() { let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"] let resolutions = ExecCommandResolution.resolveForAllowlist( diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift index cd4e234ed66..03b17b42ab2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsGatewayPrompterTests.swift @@ -52,4 +52,51 @@ struct ExecApprovalsGatewayPrompterTests { lastInputSeconds: 400) #expect(!remote) } + + // MARK: - shouldAsk + + @Test func askAlwaysPromptsRegardlessOfSecurity() { + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .always)) + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .always)) + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .always)) + } + + @Test func askOnMissPromptsOnlyForAllowlist() { + #expect(ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .onMiss)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .onMiss)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .onMiss)) + } + + @Test func askOffNeverPrompts() { + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .deny, ask: .off)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .allowlist, ask: .off)) + #expect(!ExecApprovalsGatewayPrompter._testShouldAsk(security: .full, ask: .off)) + } + + @Test func fallbackAllowlistAllowsMatchingResolvedPath() { + let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( + command: "git status", + resolvedPath: "/usr/bin/git", + askFallback: .allowlist, + allowlistPatterns: ["/usr/bin/git"]) + #expect(decision == .allowOnce) + } + + @Test func fallbackAllowlistDeniesAllowlistMiss() { + let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( + command: "git status", + resolvedPath: "/usr/bin/git", + askFallback: .allowlist, + allowlistPatterns: ["/usr/bin/rg"]) + #expect(decision == .deny) + } + + @Test func fallbackFullAllowsWhenPromptCannotBeShown() { + let decision = ExecApprovalsGatewayPrompter._testFallbackDecision( + command: "git status", + resolvedPath: "/usr/bin/git", + askFallback: .full, + allowlistPatterns: []) + #expect(decision == .allowOnce) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift new file mode 100644 index 00000000000..779b59a3499 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSkillBinTrustTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing +@testable import OpenClaw + +struct ExecSkillBinTrustTests { + @Test func `build trust index resolves skill bin paths`() throws { + let fixture = try Self.makeExecutable(named: "jq") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let trust = SkillBinsCache._testBuildTrustIndex( + report: Self.makeReport(bins: ["jq"]), + searchPaths: [fixture.root.path]) + + #expect(trust.names == ["jq"]) + #expect(trust.pathsByName["jq"] == [fixture.path]) + } + + @Test func `skill auto allow accepts trusted resolved skill bin path`() throws { + let fixture = try Self.makeExecutable(named: "jq") + defer { try? FileManager.default.removeItem(at: fixture.root) } + + let trust = SkillBinsCache._testBuildTrustIndex( + report: Self.makeReport(bins: ["jq"]), + searchPaths: [fixture.root.path]) + let resolution = ExecCommandResolution( + rawExecutable: "jq", + resolvedPath: fixture.path, + executableName: "jq", + cwd: nil) + + #expect(ExecApprovalEvaluator._testIsSkillAutoAllowed([resolution], trustedBinsByName: trust.pathsByName)) + } + + @Test func `skill auto allow rejects same basename at different path`() throws { + let trusted = try Self.makeExecutable(named: "jq") + let untrusted = try Self.makeExecutable(named: "jq") + defer { + try? FileManager.default.removeItem(at: trusted.root) + try? FileManager.default.removeItem(at: untrusted.root) + } + + let trust = SkillBinsCache._testBuildTrustIndex( + report: Self.makeReport(bins: ["jq"]), + searchPaths: [trusted.root.path]) + let resolution = ExecCommandResolution( + rawExecutable: "jq", + resolvedPath: untrusted.path, + executableName: "jq", + cwd: nil) + + #expect(!ExecApprovalEvaluator._testIsSkillAutoAllowed([resolution], trustedBinsByName: trust.pathsByName)) + } + + private static func makeExecutable(named name: String) throws -> (root: URL, path: String) { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-skill-bin-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent(name) + try "#!/bin/sh\nexit 0\n".write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], + ofItemAtPath: file.path) + return (root, file.path) + } + + private static func makeReport(bins: [String]) -> SkillsStatusReport { + SkillsStatusReport( + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [ + SkillStatus( + name: "test-skill", + description: "test", + source: "local", + filePath: "/tmp/skills/test-skill/SKILL.md", + baseDir: "/tmp/skills/test-skill", + skillKey: "test-skill", + primaryEnv: nil, + emoji: nil, + homepage: nil, + always: false, + disabled: false, + eligible: true, + requirements: SkillRequirements(bins: bins, env: [], config: []), + missing: SkillMissing(bins: [], env: [], config: []), + configChecks: [], + install: []) + ]) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift index c8928978f74..a37135ff490 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift @@ -139,6 +139,54 @@ struct LowCoverageHelperTests { #expect(emptyReport.summary.contains("Nothing is listening")) } + @Test func `port guardian remote mode does not kill docker`() { + #expect(PortGuardian._testIsExpected( + command: "com.docker.backend", + fullCommand: "com.docker.backend", + port: 18789, mode: .remote) == true) + + #expect(PortGuardian._testIsExpected( + command: "ssh", + fullCommand: "ssh -L 18789:localhost:18789 user@host", + port: 18789, mode: .remote) == true) + + #expect(PortGuardian._testIsExpected( + command: "podman", + fullCommand: "podman", + port: 18789, mode: .remote) == true) + } + + @Test func `port guardian local mode still rejects unexpected`() { + #expect(PortGuardian._testIsExpected( + command: "com.docker.backend", + fullCommand: "com.docker.backend", + port: 18789, mode: .local) == false) + + #expect(PortGuardian._testIsExpected( + command: "python", + fullCommand: "python server.py", + port: 18789, mode: .local) == false) + + #expect(PortGuardian._testIsExpected( + command: "node", + fullCommand: "node /path/to/gateway-daemon", + port: 18789, mode: .local) == true) + } + + @Test func `port guardian remote mode report accepts any listener`() { + let dockerReport = PortGuardian._testBuildReport( + port: 18789, mode: .remote, + listeners: [(pid: 99, command: "com.docker.backend", + fullCommand: "com.docker.backend", user: "me")]) + #expect(dockerReport.offenders.isEmpty) + + let localDockerReport = PortGuardian._testBuildReport( + port: 18789, mode: .local, + listeners: [(pid: 99, command: "com.docker.backend", + fullCommand: "com.docker.backend", user: "me")]) + #expect(!localDockerReport.offenders.isEmpty) + } + @Test @MainActor func `canvas scheme handler resolves files and errors`() throws { let root = FileManager().temporaryDirectory .appendingPathComponent("canvas-\(UUID().uuidString)", isDirectory: true) diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift index d33cff562f9..00f3e704708 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingRemoteAuthPromptTests.swift @@ -17,6 +17,10 @@ struct OnboardingRemoteAuthPromptTests { message: "token not configured", detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue, canRetryWithDeviceToken: false) + let bootstrapInvalid = GatewayConnectAuthError( + message: "setup code expired", + detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue, + canRetryWithDeviceToken: false) let passwordMissing = GatewayConnectAuthError( message: "password missing", detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue, @@ -33,6 +37,7 @@ struct OnboardingRemoteAuthPromptTests { #expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired) #expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch) #expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured) + #expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired) #expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired) #expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired) #expect(RemoteGatewayAuthIssue(error: unknown) == nil) @@ -88,6 +93,11 @@ struct OnboardingRemoteAuthPromptTests { remoteToken: "", remoteTokenUnsupported: false, authIssue: .gatewayTokenNotConfigured) == false) + #expect(OnboardingView.shouldShowRemoteTokenField( + showAdvancedConnection: false, + remoteToken: "", + remoteTokenUnsupported: false, + authIssue: .setupCodeExpired) == false) #expect(OnboardingView.shouldShowRemoteTokenField( showAdvancedConnection: false, remoteToken: "", @@ -106,11 +116,14 @@ struct OnboardingRemoteAuthPromptTests { @Test func `paired device success copy explains auth source`() { let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken) + let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken) let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken) let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none) #expect(pairedDevice.title == "Connected via paired device") #expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.") + #expect(bootstrap.title == "Connected with setup code") + #expect(bootstrap.detail == "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.") #expect(sharedToken.title == "Connected with gateway token") #expect(sharedToken.detail == nil) #expect(noAuth.title == "Remote gateway ready") diff --git a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift index 990c033445f..782dbd77212 100644 --- a/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/RuntimeLocatorTests.swift @@ -16,7 +16,7 @@ struct RuntimeLocatorTests { @Test func `resolve succeeds with valid node`() throws { let script = """ #!/bin/sh - echo v22.5.0 + echo v22.16.0 """ let node = try self.makeTempExecutable(contents: script) let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) @@ -25,7 +25,23 @@ struct RuntimeLocatorTests { return } #expect(res.path == node.path) - #expect(res.version == RuntimeVersion(major: 22, minor: 5, patch: 0)) + #expect(res.version == RuntimeVersion(major: 22, minor: 16, patch: 0)) + } + + @Test func `resolve fails on boundary below minimum`() throws { + let script = """ + #!/bin/sh + echo v22.15.9 + """ + let node = try self.makeTempExecutable(contents: script) + let result = RuntimeLocator.resolve(searchPaths: [node.deletingLastPathComponent().path]) + guard case let .failure(.unsupported(_, found, required, path, _)) = result else { + Issue.record("Expected unsupported error, got \(result)") + return + } + #expect(found == RuntimeVersion(major: 22, minor: 15, patch: 9)) + #expect(required == RuntimeVersion(major: 22, minor: 16, patch: 0)) + #expect(path == node.path) } @Test func `resolve fails when too old`() throws { @@ -60,7 +76,17 @@ struct RuntimeLocatorTests { @Test func `describe failure includes paths`() { let msg = RuntimeLocator.describeFailure(.notFound(searchPaths: ["/tmp/a", "/tmp/b"])) + #expect(msg.contains("Node >=22.16.0")) #expect(msg.contains("PATH searched: /tmp/a:/tmp/b")) + + let parseMsg = RuntimeLocator.describeFailure( + .versionParse( + kind: .node, + raw: "garbage", + path: "/usr/local/bin/node", + searchPaths: ["/usr/local/bin"], + )) + #expect(parseMsg.contains("Node >=22.16.0")) } @Test func `runtime version parses with leading V and metadata`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift index eac7ceea37d..fcf3f3b1158 100644 --- a/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/VoiceWakeRuntimeTests.swift @@ -74,4 +74,22 @@ struct VoiceWakeRuntimeTests { let config = WakeWordGateConfig(triggers: ["openclaw"], minPostTriggerGap: 0.3) #expect(WakeWordGate.match(transcript: transcript, segments: segments, config: config)?.command == "do thing") } + + @Test func `gate command text handles foreign string ranges`() { + let transcript = "hey openclaw do thing" + let other = "do thing" + let foreignRange = other.range(of: "do") + let segments = [ + WakeWordSegment(text: "hey", start: 0.0, duration: 0.1, range: transcript.range(of: "hey")), + WakeWordSegment(text: "openclaw", start: 0.2, duration: 0.1, range: transcript.range(of: "openclaw")), + WakeWordSegment(text: "do", start: 0.9, duration: 0.1, range: foreignRange), + WakeWordSegment(text: "thing", start: 1.1, duration: 0.1, range: nil), + ] + + #expect( + WakeWordGate.commandText( + transcript: transcript, + segments: segments, + triggerEndTime: 0.3) == "do thing") + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift index 48f01e09c6a..c5a74c9a9aa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift @@ -34,6 +34,13 @@ public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable public struct OpenClawChatSessionsDefaults: Codable, Sendable { public let model: String? public let contextTokens: Int? + public let mainSessionKey: String? + + public init(model: String?, contextTokens: Int?, mainSessionKey: String? = nil) { + self.model = model + self.contextTokens = contextTokens + self.mainSessionKey = mainSessionKey + } } public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable { @@ -69,4 +76,18 @@ public struct OpenClawChatSessionsListResponse: Codable, Sendable { public let count: Int? public let defaults: OpenClawChatSessionsDefaults? public let sessions: [OpenClawChatSessionEntry] + + public init( + ts: Double?, + path: String?, + count: Int?, + defaults: OpenClawChatSessionsDefaults?, + sessions: [OpenClawChatSessionEntry]) + { + self.ts = ts + self.path = path + self.count = count + self.defaults = defaults + self.sessions = sessions + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index bfbd33bfda3..49bd91db372 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -27,11 +27,19 @@ public protocol OpenClawChatTransport: Sendable { func events() -> AsyncStream func setActiveSessionKey(_ sessionKey: String) async throws + func resetSession(sessionKey: String) async throws } extension OpenClawChatTransport { public func setActiveSessionKey(_: String) async throws {} + public func resetSession(sessionKey _: String) async throws { + throw NSError( + domain: "OpenClawChatTransport", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"]) + } + public func abortRun(sessionKey _: String, runId _: String) async throws { throw NSError( domain: "OpenClawChatTransport", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index a136469fbd8..92413aefe64 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -138,21 +138,23 @@ public final class OpenClawChatViewModel { let now = Date().timeIntervalSince1970 * 1000 let cutoff = now - (24 * 60 * 60 * 1000) let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + let mainSessionKey = self.resolvedMainSessionKey var result: [OpenClawChatSessionEntry] = [] var included = Set() - // Always show the main session first, even if it hasn't been updated recently. - if let main = sorted.first(where: { $0.key == "main" }) { + // Always show the resolved main session first, even if it hasn't been updated recently. + if let main = sorted.first(where: { $0.key == mainSessionKey }) { result.append(main) included.insert(main.key) } else { - result.append(self.placeholderSession(key: "main")) - included.insert("main") + result.append(self.placeholderSession(key: mainSessionKey)) + included.insert(mainSessionKey) } for entry in sorted { guard !included.contains(entry.key) else { continue } + guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue } guard (entry.updatedAt ?? 0) >= cutoff else { continue } result.append(entry) included.insert(entry.key) @@ -169,6 +171,18 @@ public final class OpenClawChatViewModel { return result } + private var resolvedMainSessionKey: String { + let trimmed = self.sessionDefaults?.mainSessionKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main" + } + + private static func isHiddenInternalSession(_ key: String) -> Bool { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding") + } + public var showsModelPicker: Bool { !self.modelChoices.isEmpty } @@ -365,10 +379,19 @@ public final class OpenClawChatViewModel { return "\(message.role)|\(timestamp)|\(text)" } + private static let resetTriggers: Set = ["/new", "/reset", "/clear"] + private func performSend() async { guard !self.isSending else { return } let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty || !self.attachments.isEmpty else { return } + + if Self.resetTriggers.contains(trimmed.lowercased()) { + self.input = "" + await self.performReset() + return + } + let sessionKey = self.sessionKey guard self.healthOK else { @@ -499,6 +522,22 @@ public final class OpenClawChatViewModel { await self.bootstrap() } + private func performReset() async { + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + do { + try await self.transport.resetSession(sessionKey: self.sessionKey) + } catch { + self.errorText = error.localizedDescription + chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)") + return + } + + await self.bootstrap() + } + private func performSelectThinkingLevel(_ level: String) async { let next = Self.normalizedThinkingLevel(level) ?? "off" guard next != self.thinkingLevel else { return } @@ -549,7 +588,9 @@ public final class OpenClawChatViewModel { sessionKey: sessionKey, model: nextModelRef) guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else { - self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false) + // Keep older successful patches as rollback state, but do not replay + // stale UI/session state over a newer in-flight or completed selection. + self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = next return } self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift index 20b3761668b..5f1440ccb1a 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/DeepLinks.swift @@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { public let host: String public let port: Int public let tls: Bool + public let bootstrapToken: String? public let token: String? public let password: String? - public init(host: String, port: Int, tls: Bool, token: String?, password: String?) { + public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) { self.host = host self.port = port self.tls = tls + self.bootstrapToken = bootstrapToken self.token = token self.password = password } @@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return URL(string: "\(scheme)://\(self.host):\(self.port)") } - /// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`). + /// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`). public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? { guard let data = Self.decodeBase64Url(code) else { return nil } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } @@ -41,9 +43,16 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable { return nil } let port = parsed.port ?? (tls ? 443 : 18789) + let bootstrapToken = json["bootstrapToken"] as? String let token = json["token"] as? String let password = json["password"] as? String - return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password) + return GatewayConnectDeepLink( + host: hostname, + port: port, + tls: tls, + bootstrapToken: bootstrapToken, + token: token, + password: password) } private static func decodeBase64Url(_ input: String) -> Data? { @@ -140,6 +149,7 @@ public enum DeepLinkParser { host: hostParam, port: port, tls: tls, + bootstrapToken: nil, token: query["token"], password: query["password"])) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift index 4848043980b..2c3da84af68 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift @@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable { public enum GatewayAuthSource: String, Sendable { case deviceToken = "device-token" case sharedToken = "shared-token" + case bootstrapToken = "bootstrap-token" case password = "password" case none = "none" } @@ -131,6 +132,22 @@ private let defaultOperatorConnectScopes: [String] = [ "operator.pairing", ] +private extension String { + var nilIfEmpty: String? { + self.isEmpty ? nil : self + } +} + +private struct SelectedConnectAuth: Sendable { + let authToken: String? + let authBootstrapToken: String? + let authDeviceToken: String? + let authPassword: String? + let signatureToken: String? + let storedToken: String? + let authSource: GatewayAuthSource +} + private enum GatewayConnectErrorCodes { static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue @@ -154,6 +171,7 @@ public actor GatewayChannelActor { private var connectWaiters: [CheckedContinuation] = [] private var url: URL private var token: String? + private var bootstrapToken: String? private var password: String? private let session: WebSocketSessioning private var backoffMs: Double = 500 @@ -185,6 +203,7 @@ public actor GatewayChannelActor { public init( url: URL, token: String?, + bootstrapToken: String? = nil, password: String? = nil, session: WebSocketSessionBox? = nil, pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil, @@ -193,6 +212,7 @@ public actor GatewayChannelActor { { self.url = url self.token = token + self.bootstrapToken = bootstrapToken self.password = password self.session = session?.session ?? URLSession(configuration: .default) self.pushHandler = pushHandler @@ -398,39 +418,24 @@ public actor GatewayChannelActor { } let includeDeviceIdentity = options.includeDeviceIdentity let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil - let storedToken = - (includeDeviceIdentity && identity != nil) - ? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token - : nil - let shouldUseDeviceRetryToken = - includeDeviceIdentity && self.pendingDeviceTokenRetry && - storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint() - if shouldUseDeviceRetryToken { + let selectedAuth = self.selectConnectAuth( + role: role, + includeDeviceIdentity: includeDeviceIdentity, + deviceId: identity?.deviceId) + if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry { self.pendingDeviceTokenRetry = false } - // Keep shared credentials explicit when provided. Device token retry is attached - // only on a bounded second attempt after token mismatch. - let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil) - let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil - let authSource: GatewayAuthSource - if authDeviceToken != nil || (self.token == nil && storedToken != nil) { - authSource = .deviceToken - } else if authToken != nil { - authSource = .sharedToken - } else if self.password != nil { - authSource = .password - } else { - authSource = .none - } - self.lastAuthSource = authSource - self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)") - if let authToken { + self.lastAuthSource = selectedAuth.authSource + self.logger.info("gateway connect auth=\(selectedAuth.authSource.rawValue, privacy: .public)") + if let authToken = selectedAuth.authToken { var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)] - if let authDeviceToken { + if let authDeviceToken = selectedAuth.authDeviceToken { auth["deviceToken"] = ProtoAnyCodable(authDeviceToken) } params["auth"] = ProtoAnyCodable(auth) - } else if let password = self.password { + } else if let authBootstrapToken = selectedAuth.authBootstrapToken { + params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)]) + } else if let password = selectedAuth.authPassword { params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)]) } let signedAtMs = Int(Date().timeIntervalSince1970 * 1000) @@ -443,7 +448,7 @@ public actor GatewayChannelActor { role: role, scopes: scopes, signedAtMs: signedAtMs, - token: authToken, + token: selectedAuth.signatureToken, nonce: connectNonce, platform: platform, deviceFamily: InstanceIdentity.deviceFamily) @@ -472,14 +477,14 @@ public actor GatewayChannelActor { } catch { let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken( error: error, - explicitGatewayToken: self.token, - storedToken: storedToken, - attemptedDeviceTokenRetry: authDeviceToken != nil) + explicitGatewayToken: self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, + storedToken: selectedAuth.storedToken, + attemptedDeviceTokenRetry: selectedAuth.authDeviceToken != nil) if shouldRetryWithDeviceToken { self.pendingDeviceTokenRetry = true self.deviceTokenRetryBudgetUsed = true self.backoffMs = min(self.backoffMs, 250) - } else if authDeviceToken != nil, + } else if selectedAuth.authDeviceToken != nil, let identity, self.shouldClearStoredDeviceTokenAfterRetry(error) { @@ -490,6 +495,50 @@ public actor GatewayChannelActor { } } + private func selectConnectAuth( + role: String, + includeDeviceIdentity: Bool, + deviceId: String? + ) -> SelectedConnectAuth { + let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let explicitBootstrapToken = + self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + let storedToken = + (includeDeviceIdentity && deviceId != nil) + ? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token + : nil + let shouldUseDeviceRetryToken = + includeDeviceIdentity && self.pendingDeviceTokenRetry && + storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint() + let authToken = + explicitToken ?? + (includeDeviceIdentity && explicitPassword == nil && + (explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil) + let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil + let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil + let authSource: GatewayAuthSource + if authDeviceToken != nil || (explicitToken == nil && authToken != nil) { + authSource = .deviceToken + } else if authToken != nil { + authSource = .sharedToken + } else if authBootstrapToken != nil { + authSource = .bootstrapToken + } else if explicitPassword != nil { + authSource = .password + } else { + authSource = .none + } + return SelectedConnectAuth( + authToken: authToken, + authBootstrapToken: authBootstrapToken, + authDeviceToken: authDeviceToken, + authPassword: explicitPassword, + signatureToken: authToken ?? authBootstrapToken, + storedToken: storedToken, + authSource: authSource) + } + private func handleConnectResponse( _ res: ResponseFrame, identity: DeviceIdentity?, @@ -892,7 +941,8 @@ public actor GatewayChannelActor { return (id: id, data: data) } catch { self.logger.error( - "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)") + "gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) throw error } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift index 3b1d97059a3..7ef7f466476 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayErrors.swift @@ -5,6 +5,7 @@ public enum GatewayConnectAuthDetailCode: String, Sendable { case authRequired = "AUTH_REQUIRED" case authUnauthorized = "AUTH_UNAUTHORIZED" case authTokenMismatch = "AUTH_TOKEN_MISMATCH" + case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID" case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH" case authTokenMissing = "AUTH_TOKEN_MISSING" case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED" @@ -92,6 +93,7 @@ public struct GatewayConnectAuthError: LocalizedError, Sendable { public var isNonRecoverable: Bool { switch self.detail { case .authTokenMissing, + .authBootstrapTokenInvalid, .authTokenNotConfigured, .authPasswordMissing, .authPasswordMismatch, diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift index 378ad10e365..945e482bbbf 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayNodeSession.swift @@ -64,6 +64,7 @@ public actor GatewayNodeSession { private var channel: GatewayChannelActor? private var activeURL: URL? private var activeToken: String? + private var activeBootstrapToken: String? private var activePassword: String? private var activeConnectOptionsKey: String? private var connectOptions: GatewayConnectOptions? @@ -194,6 +195,7 @@ public actor GatewayNodeSession { public func connect( url: URL, token: String?, + bootstrapToken: String?, password: String?, connectOptions: GatewayConnectOptions, sessionBox: WebSocketSessionBox?, @@ -204,6 +206,7 @@ public actor GatewayNodeSession { let nextOptionsKey = self.connectOptionsKey(connectOptions) let shouldReconnect = self.activeURL != url || self.activeToken != token || + self.activeBootstrapToken != bootstrapToken || self.activePassword != password || self.activeConnectOptionsKey != nextOptionsKey || self.channel == nil @@ -221,6 +224,7 @@ public actor GatewayNodeSession { let channel = GatewayChannelActor( url: url, token: token, + bootstrapToken: bootstrapToken, password: password, session: sessionBox, pushHandler: { [weak self] push in @@ -233,6 +237,7 @@ public actor GatewayNodeSession { self.channel = channel self.activeURL = url self.activeToken = token + self.activeBootstrapToken = bootstrapToken self.activePassword = password self.activeConnectOptionsKey = nextOptionsKey } @@ -257,6 +262,7 @@ public actor GatewayNodeSession { self.channel = nil self.activeURL = nil self.activeToken = nil + self.activeBootstrapToken = nil self.activePassword = nil self.activeConnectOptionsKey = nil self.hasEverConnected = false diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index b743060f6c0..3003ae79f7b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1106,6 +1106,7 @@ public struct PushTestResult: Codable, Sendable { public let tokensuffix: String public let topic: String public let environment: String + public let transport: String public init( ok: Bool, @@ -1114,7 +1115,8 @@ public struct PushTestResult: Codable, Sendable { reason: String?, tokensuffix: String, topic: String, - environment: String) + environment: String, + transport: String) { self.ok = ok self.status = status @@ -1123,6 +1125,7 @@ public struct PushTestResult: Codable, Sendable { self.tokensuffix = tokensuffix self.topic = topic self.environment = environment + self.transport = transport } private enum CodingKeys: String, CodingKey { @@ -1133,6 +1136,7 @@ public struct PushTestResult: Codable, Sendable { case tokensuffix = "tokenSuffix" case topic case environment + case transport } } @@ -1318,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let key: String public let label: AnyCodable? public let thinkinglevel: AnyCodable? + public let fastmode: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? public let responseusage: AnyCodable? @@ -1339,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable { key: String, label: AnyCodable?, thinkinglevel: AnyCodable?, + fastmode: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, responseusage: AnyCodable?, @@ -1359,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.key = key self.label = label self.thinkinglevel = thinkinglevel + self.fastmode = fastmode self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel self.responseusage = responseusage @@ -1381,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable { case key case label case thinkinglevel = "thinkingLevel" + case fastmode = "fastMode" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" case responseusage = "responseUsage" diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index abfd267a66c..6d1fa88e569 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -83,6 +83,7 @@ private func makeViewModel( historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil, setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil, initialThinkingLevel: String? = nil, @@ -93,6 +94,7 @@ private func makeViewModel( historyResponses: historyResponses, sessionsResponses: sessionsResponses, modelResponses: modelResponses, + resetSessionHook: resetSessionHook, setSessionModelHook: setSessionModelHook, setSessionThinkingHook: setSessionThinkingHook) let vm = await MainActor.run { @@ -199,6 +201,7 @@ private actor TestChatTransportState { var historyCallCount: Int = 0 var sessionsCallCount: Int = 0 var modelsCallCount: Int = 0 + var resetSessionKeys: [String] = [] var sentRunIds: [String] = [] var sentThinkingLevels: [String] = [] var abortedRunIds: [String] = [] @@ -211,6 +214,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let historyResponses: [OpenClawChatHistoryPayload] private let sessionsResponses: [OpenClawChatSessionsListResponse] private let modelResponses: [[OpenClawChatModelChoice]] + private let resetSessionHook: (@Sendable (String) async throws -> Void)? private let setSessionModelHook: (@Sendable (String?) async throws -> Void)? private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)? @@ -221,12 +225,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil, setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil) { self.historyResponses = historyResponses self.sessionsResponses = sessionsResponses self.modelResponses = modelResponses + self.resetSessionHook = resetSessionHook self.setSessionModelHook = setSessionModelHook self.setSessionThinkingHook = setSessionThinkingHook var cont: AsyncStream.Continuation! @@ -301,6 +307,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } } + func resetSession(sessionKey: String) async throws { + await self.state.resetSessionKeysAppend(sessionKey) + if let resetSessionHook = self.resetSessionHook { + try await resetSessionHook(sessionKey) + } + } + func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws { await self.state.patchedThinkingLevelsAppend(thinkingLevel) if let setSessionThinkingHook = self.setSessionThinkingHook { @@ -336,6 +349,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor func patchedThinkingLevels() async -> [String] { await self.state.patchedThinkingLevels } + + func resetSessionKeys() async -> [String] { + await self.state.resetSessionKeys + } } extension TestChatTransportState { @@ -370,6 +387,10 @@ extension TestChatTransportState { fileprivate func patchedThinkingLevelsAppend(_ v: String) { self.patchedThinkingLevels.append(v) } + + fileprivate func resetSessionKeysAppend(_ v: String) { + self.resetSessionKeys.append(v) + } } @Suite struct ChatViewModelTests { @@ -592,6 +613,151 @@ extension TestChatTransportState { #expect(keys == ["main", "custom"]) } + @Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (30 * 60 * 1000) + let recentOlder = now - (90 * 60 * 1000) + let history = historyPayload(sessionKey: "Luke’s MacBook Pro", sessionId: "sess-main") + let sessions = OpenClawChatSessionsListResponse( + ts: now, + path: nil, + count: 2, + defaults: OpenClawChatSessionsDefaults( + model: nil, + contextTokens: nil, + mainSessionKey: "Luke’s MacBook Pro"), + sessions: [ + OpenClawChatSessionEntry( + key: "Luke’s MacBook Pro", + kind: nil, + displayName: "Luke’s MacBook Pro", + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: nil, + model: nil, + contextTokens: nil), + sessionEntry(key: "recent-1", updatedAt: recentOlder), + ]) + + let (_, vm) = await makeViewModel( + sessionKey: "Luke’s MacBook Pro", + historyResponses: [history], + sessionsResponses: [sessions]) + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["Luke’s MacBook Pro", "recent-1"]) + } + + @Test func sessionChoicesHideInternalOnboardingSession() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (2 * 60 * 1000) + let recentOlder = now - (5 * 60 * 1000) + let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main") + let sessions = OpenClawChatSessionsListResponse( + ts: now, + path: nil, + count: 2, + defaults: OpenClawChatSessionsDefaults( + model: nil, + contextTokens: nil, + mainSessionKey: "agent:main:main"), + sessions: [ + OpenClawChatSessionEntry( + key: "agent:main:onboarding", + kind: nil, + displayName: "Luke’s MacBook Pro", + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: nil, + model: nil, + contextTokens: nil), + OpenClawChatSessionEntry( + key: "agent:main:main", + kind: nil, + displayName: "Luke’s MacBook Pro", + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recentOlder, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: nil, + model: nil, + contextTokens: nil), + ]) + + let (_, vm) = await makeViewModel( + sessionKey: "agent:main:main", + historyResponses: [history], + sessionsResponses: [sessions]) + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["agent:main:main"]) + } + + @Test func resetTriggerResetsSessionAndReloadsHistory() async throws { + let before = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "before reset", timestamp: 1), + ]) + let after = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "after reset", timestamp: 2), + ]) + + let (transport, vm) = await makeViewModel(historyResponses: [before, after]) + try await loadAndWaitBootstrap(vm: vm) + try await waitUntil("initial history loaded") { + await MainActor.run { vm.messages.first?.content.first?.text == "before reset" } + } + + await MainActor.run { + vm.input = "/new" + vm.send() + } + + try await waitUntil("reset called") { + await transport.resetSessionKeys() == ["main"] + } + try await waitUntil("history reloaded") { + await MainActor.run { vm.messages.first?.content.first?.text == "after reset" } + } + #expect(await transport.lastSentRunId() == nil) + } + @Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() @@ -758,7 +924,8 @@ extension TestChatTransportState { } #expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro") - #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4-pro") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") } @Test func sendWaitsForInFlightModelPatchToFinish() async throws { @@ -852,11 +1019,15 @@ extension TestChatTransportState { } try await waitUntil("older model completion wins after latest failure") { - await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" } + await MainActor.run { + vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" && + vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai" + } } #expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4") - #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } @@ -1012,12 +1183,17 @@ extension TestChatTransportState { } try await waitUntil("late model completion updates only the original session") { - await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" } + await MainActor.run { + vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" && + vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai" + } } #expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4") - #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.modelProvider } == nil) #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift index 8bbf4f8a650..79613b310ff 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/DeepLinksSecurityTests.swift @@ -20,11 +20,17 @@ import Testing string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")! #expect( DeepLinkParser.parse(url) == .gateway( - .init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil))) + .init( + host: "127.0.0.1", + port: 18789, + tls: false, + bootstrapToken: nil, + token: "abc", + password: nil))) } @Test func setupCodeRejectsInsecureNonLoopbackWs() { - let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"# let encoded = Data(payload.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") @@ -34,7 +40,7 @@ import Testing } @Test func setupCodeRejectsInsecurePrefixBypassHost() { - let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"# let encoded = Data(payload.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") @@ -44,7 +50,7 @@ import Testing } @Test func setupCodeAllowsLoopbackWs() { - let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"# + let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"# let encoded = Data(payload.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") @@ -55,7 +61,8 @@ import Testing host: "127.0.0.1", port: 18789, tls: false, - token: "tok", + bootstrapToken: "tok", + token: nil, password: nil)) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift new file mode 100644 index 00000000000..92d3e1292de --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayErrorsTests.swift @@ -0,0 +1,14 @@ +import OpenClawKit +import Testing + +@Suite struct GatewayErrorsTests { + @Test func bootstrapTokenInvalidIsNonRecoverable() { + let error = GatewayConnectAuthError( + message: "setup code expired", + detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue, + canRetryWithDeviceToken: false) + + #expect(error.isNonRecoverable) + #expect(error.detail == .authBootstrapTokenInvalid) + } +} diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift index a48015e1100..183fc385d8c 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/GatewayNodeSessionTests.swift @@ -266,6 +266,7 @@ struct GatewayNodeSessionTests { try await gateway.connect( url: URL(string: "ws://example.invalid")!, token: nil, + bootstrapToken: nil, password: nil, connectOptions: options, sessionBox: WebSocketSessionBox(session: session), 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/changelog/fragments/toolcall-id-malformed-name-inference.md b/changelog/fragments/toolcall-id-malformed-name-inference.md new file mode 100644 index 00000000000..6af2b986f34 --- /dev/null +++ b/changelog/fragments/toolcall-id-malformed-name-inference.md @@ -0,0 +1 @@ +- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers. 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/automation/cron-jobs.md b/docs/automation/cron-jobs.md index effa8f3ab81..cb27380416b 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -25,7 +25,9 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting) - Jobs persist under `~/.openclaw/cron/` so restarts don’t lose schedules. - Two execution styles: - **Main session**: enqueue a system event, then run on the next heartbeat. - - **Isolated**: run a dedicated agent turn in `cron:`, with delivery (announce by default or none). + - **Isolated**: run a dedicated agent turn in `cron:` or a custom session, with delivery (announce by default or none). + - **Current session**: bind to the session where the cron is created (`sessionTarget: "current"`). + - **Custom session**: run in a persistent named session (`sessionTarget: "session:custom-id"`). - Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. - Webhook posting is per job via `delivery.mode = "webhook"` + `delivery.to = ""`. - Legacy fallback remains for stored jobs with `notify: true` when `cron.webhook` is set, migrate those jobs to webhook delivery mode. @@ -86,6 +88,14 @@ Think of a cron job as: **when** to run + **what** to do. 2. **Choose where it runs** - `sessionTarget: "main"` → run during the next heartbeat with main context. - `sessionTarget: "isolated"` → run a dedicated agent turn in `cron:`. + - `sessionTarget: "current"` → bind to the current session (resolved at creation time to `session:`). + - `sessionTarget: "session:custom-id"` → run in a persistent named session that maintains context across runs. + + Default behavior (unchanged): + - `systemEvent` payloads default to `main` + - `agentTurn` payloads default to `isolated` + + To use current session binding, explicitly set `sessionTarget: "current"`. 3. **Choose the payload** - Main session → `payload.kind = "systemEvent"` @@ -147,12 +157,13 @@ See [Heartbeat](/gateway/heartbeat). #### Isolated jobs (dedicated cron sessions) -Isolated jobs run a dedicated agent turn in session `cron:`. +Isolated jobs run a dedicated agent turn in session `cron:` or a custom session. Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. -- Each run starts a **fresh session id** (no prior conversation carry-over). +- Each run starts a **fresh session id** (no prior conversation carry-over), unless using a custom session. +- Custom sessions (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries. - Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`). - `delivery.mode` chooses what happens: - `announce`: deliver a summary to the target channel and post a brief summary to the main session. @@ -321,12 +332,42 @@ Recurring, isolated job with delivery: } ``` +Recurring job bound to current session (auto-resolved at creation): + +```json +{ + "name": "Daily standup", + "schedule": { "kind": "cron", "expr": "0 9 * * *" }, + "sessionTarget": "current", + "payload": { + "kind": "agentTurn", + "message": "Summarize yesterday's progress." + } +} +``` + +Recurring job in a custom persistent session: + +```json +{ + "name": "Project monitor", + "schedule": { "kind": "every", "everyMs": 300000 }, + "sessionTarget": "session:project-alpha-monitor", + "payload": { + "kind": "agentTurn", + "message": "Check project status and update the running log." + } +} +``` + Notes: - `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). - `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). - `everyMs` is milliseconds. -- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`. +- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:"`. +- `"current"` is resolved to `"session:"` at creation time. +- Custom sessions (`session:xxx`) maintain persistent context across runs. - Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`), `delivery`. - `wakeMode` defaults to `"now"` when omitted. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index 9676d960d23..09f9187c368 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -219,13 +219,13 @@ See [Lobster](/tools/lobster) for full usage and examples. Both heartbeat and cron can interact with the main session, but differently: -| | Heartbeat | Cron (main) | Cron (isolated) | -| ------- | ------------------------------- | ------------------------ | -------------------------- | -| Session | Main | Main (via system event) | `cron:` | -| History | Shared | Shared | Fresh each run | -| Context | Full | Full | None (starts clean) | -| Model | Main session model | Main session model | Can override | -| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | +| | Heartbeat | Cron (main) | Cron (isolated) | +| ------- | ------------------------------- | ------------------------ | ----------------------------------------------- | +| Session | Main | Main (via system event) | `cron:` or custom session | +| History | Shared | Shared | Fresh each run (isolated) / Persistent (custom) | +| Context | Full | Full | None (isolated) / Cumulative (custom) | +| Model | Main session model | Main session model | Can override | +| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) | ### When to use main session cron diff --git a/docs/brave-search.md b/docs/brave-search.md index a8bba5c3e91..4a541690431 100644 --- a/docs/brave-search.md +++ b/docs/brave-search.md @@ -73,7 +73,7 @@ await web_search({ ## Notes - OpenClaw uses the Brave **Search** plan. If you have a legacy subscription (e.g. the original Free plan with 2,000 queries/month), it remains valid but does not include newer features like LLM Context or higher rate limits. -- Each Brave plan includes **$5/month in free credit** (renewing). The Search plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans. +- Each Brave plan includes **\$5/month in free credit** (renewing). The Search plan costs \$5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans. - The Search plan includes the LLM Context endpoint and AI inference rights. Storing results to train or tune models requires a plan with explicit storage rights. See the Brave [Terms of Service](https://api-dashboard.search.brave.com/terms-of-service). - Results are cached for 15 minutes by default (configurable via `cacheTtlMinutes`). diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index 2d824359311..63c5806ebae 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`): You can override the store path via `session.store` and `{agentId}` templating. +Gateway and ACP session discovery also scans disk-backed agent stores under the +default `agents/` root and under templated `session.store` roots. Discovered +stores must stay inside that resolved agent root and use a regular +`sessions.json` file. Symlinks and out-of-root paths are ignored. + ## WebChat behavior WebChat attaches to the **selected agent** and defaults to the agent’s main diff --git a/docs/channels/feishu.md b/docs/channels/feishu.md index 67e4fd60379..467fc57c0fe 100644 --- a/docs/channels/feishu.md +++ b/docs/channels/feishu.md @@ -193,16 +193,18 @@ Edit `~/.openclaw/openclaw.json`: } ``` -If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. +If you use `connectionMode: "webhook"`, set both `verificationToken` and `encryptKey`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address. -#### Verification Token (webhook mode) +#### Verification Token and Encrypt Key (webhook mode) -When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value: +When using webhook mode, set both `channels.feishu.verificationToken` and `channels.feishu.encryptKey` in your config. To get the values: 1. In Feishu Open Platform, open your app 2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调) 3. Open the **Encryption** tab (加密策略) -4. Copy **Verification Token** +4. Copy **Verification Token** and **Encrypt Key** + +The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section. ![Verification Token location](../images/feishu-verification-token.png) @@ -600,6 +602,7 @@ Key options: | `channels.feishu.connectionMode` | Event transport mode | `websocket` | | `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` | | `channels.feishu.verificationToken` | Required for webhook mode | - | +| `channels.feishu.encryptKey` | Required for webhook mode | - | | `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` | | `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` | | `channels.feishu.webhookPort` | Webhook bind port | `3000` | 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/mattermost.md b/docs/channels/mattermost.md index 6a7ee8bb472..1e3e3f4bad2 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -129,6 +129,35 @@ Notes: - `onchar` still responds to explicit @mentions. - `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred. +## Threading and sessions + +Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the +main channel or start a thread under the triggering post. + +- `off` (default): only reply in a thread when the inbound post is already in one. +- `first`: for top-level channel/group posts, start a thread under that post and route the + conversation to a thread-scoped session. +- `all`: same behavior as `first` for Mattermost today. +- Direct messages ignore this setting and stay non-threaded. + +Config example: + +```json5 +{ + channels: { + mattermost: { + replyToMode: "all", + }, + }, +} +``` + +Notes: + +- Thread-scoped sessions use the triggering post id as the thread root. +- `first` and `all` are currently equivalent because once Mattermost has a thread root, + follow-up chunks and media continue in that same thread. + ## Access control (DMs) - Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code). diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 9c4a583e1b5..a24f20c69df 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -114,11 +114,11 @@ Example: **Teams + channel allowlist** - Scope group/channel replies by listing teams and channels under `channels.msteams.teams`. -- Keys can be team IDs or names; channel keys can be conversation IDs or names. +- Keys should use stable team IDs and channel conversation IDs. - When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated). - The configure wizard accepts `Team/Channel` entries and stores them for you. - On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow) - and logs the mapping; unresolved entries are kept as typed. + and logs the mapping; unresolved team/channel names are kept as typed but ignored for routing by default unless `channels.msteams.dangerouslyAllowNameMatching: true` is enabled. Example: @@ -457,7 +457,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns): - `channels.msteams.webhook.path` (default `/api/messages`) - `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing) - `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available. -- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching. +- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching and direct team/channel name routing. - `channels.msteams.textChunkLimit`: outbound text chunk size. - `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking. - `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains). diff --git a/docs/channels/pairing.md b/docs/channels/pairing.md index d402de16662..1ba3c6c92f2 100644 --- a/docs/channels/pairing.md +++ b/docs/channels/pairing.md @@ -72,7 +72,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire The setup code is a base64-encoded JSON payload that contains: - `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`) -- `token`: a short-lived pairing token +- `bootstrapToken`: a short-lived single-device bootstrap token used for the initial pairing handshake Treat the setup code like a password while it is valid. 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/channels/slack.md b/docs/channels/slack.md index c099120c699..aa9127ea630 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -169,15 +169,15 @@ For actions/directory reads, user token can be preferred when configured. For wr - `allowlist` - `disabled` - Channel allowlist lives under `channels.slack.channels`. + Channel allowlist lives under `channels.slack.channels` and should use stable channel IDs. Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set). Name/ID resolution: - channel allowlist entries and DM allowlist entries are resolved at startup when token access allows - - unresolved entries are kept as configured - - inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` + - unresolved channel-name entries are kept as configured but ignored for routing by default + - inbound authorization and channel routing are ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true` @@ -190,7 +190,7 @@ For actions/directory reads, user token can be preferred when configured. For wr - mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`) - implicit reply-to-bot thread behavior - Per-channel controls (`channels.slack.channels.`): + Per-channel controls (`channels.slack.channels.`; names only via startup resolution or `dangerouslyAllowNameMatching`): - `requireMention` - `users` (allowlist) @@ -218,6 +218,55 @@ For actions/directory reads, user token can be preferred when configured. For wr - if encoded option values exceed Slack limits, the flow falls back to buttons - For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value. +## Interactive replies + +Slack can render agent-authored interactive reply controls, but this feature is disabled by default. + +Enable it globally: + +```json5 +{ + channels: { + slack: { + capabilities: { + interactiveReplies: true, + }, + }, + }, +} +``` + +Or enable it for one Slack account only: + +```json5 +{ + channels: { + slack: { + accounts: { + ops: { + capabilities: { + interactiveReplies: true, + }, + }, + }, + }, + }, +} +``` + +When enabled, agents can emit Slack-only reply directives: + +- `[[slack_buttons: Approve:approve, Reject:reject]]` +- `[[slack_select: Choose a target | Canary:canary, Production:production]]` + +These directives compile into Slack Block Kit and route clicks or selections back through the existing Slack interaction event path. + +Notes: + +- This is Slack-specific UI. Other channels do not translate Slack Block Kit directives into their own button systems. +- The interactive callback values are OpenClaw-generated opaque tokens, not raw agent-authored values. +- If generated interactive blocks would exceed Slack Block Kit limits, OpenClaw falls back to the original text reply instead of sending an invalid blocks payload. + Default slash command settings: - `enabled: false` diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index f2467d12b0a..a0c679988d3 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -335,9 +335,10 @@ curl "https://api.telegram.org/bot/getUpdates" If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured. - Common setup failure: + Common setup failures: - - `setMyCommands failed` usually means outbound DNS/HTTPS to `api.telegram.org` is blocked. + - `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the Telegram menu still overflowed after trimming; reduce plugin/skill/custom commands or disable `channels.telegram.commands.native`. + - `setMyCommands failed` with network/fetch errors usually means outbound DNS/HTTPS to `api.telegram.org` is blocked. ### Device pairing commands (`device-pair` plugin) @@ -843,7 +844,8 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \ - authorize your sender identity (pairing and/or numeric `allowFrom`) - command authorization still applies even when group policy is `open` - - `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org` + - `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the native menu has too many entries; reduce plugin/skill/custom commands or disable native menus + - `setMyCommands failed` with network/fetch errors usually indicates DNS/HTTPS reachability issues to `api.telegram.org` diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md index 2848947c479..a7850801948 100644 --- a/docs/channels/troubleshooting.md +++ b/docs/channels/troubleshooting.md @@ -44,12 +44,13 @@ Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whats ### Telegram failure signatures -| Symptom | Fastest check | Fix | -| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | -| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. | -| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. | -| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. | -| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. | +| Symptom | Fastest check | Fix | +| ----------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- | +| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. | +| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. | +| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. | +| `setMyCommands` rejected at startup | Inspect logs for `BOT_COMMANDS_TOO_MUCH` | Reduce plugin/skill/custom Telegram commands or disable native menus. | +| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. | Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting) diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 9b62244e234..58bd2a43923 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -86,11 +86,13 @@ Approve via: - Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. - Restrict to an allowlist with: - `channels.zalouser.groupPolicy = "allowlist"` - - `channels.zalouser.groups` (keys are group IDs or names; controls which groups are allowed) + - `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup when possible) - `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot) - Block all groups: `channels.zalouser.groupPolicy = "disabled"`. - The configure wizard can prompt for group allowlists. -- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. +- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping. +- Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled. +- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable group-name matching. - If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks. - Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`). diff --git a/docs/ci.md b/docs/ci.md index 16a7e670964..e8710b87cb1 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -9,32 +9,32 @@ read_when: # CI Pipeline -The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only docs or native code changed. +The CI runs on every push to `main` and every pull request. It uses smart scoping to skip expensive jobs when only unrelated areas changed. ## Job Overview -| Job | Purpose | When it runs | -| ----------------- | ------------------------------------------------------- | ------------------------------------------------- | -| `docs-scope` | Detect docs-only changes | Always | -| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-docs PRs | -| `check` | TypeScript types, lint, format | Push to `main`, or PRs with Node-relevant changes | -| `check-docs` | Markdown lint + broken link check | Docs changed | -| `code-analysis` | LOC threshold check (1000 lines) | PRs only | -| `secrets` | Detect leaked secrets | Always | -| `build-artifacts` | Build dist once, share with other jobs | Non-docs, node changes | -| `release-check` | Validate npm pack contents | After build | -| `checks` | Node/Bun tests + protocol check | Non-docs, node changes | -| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes | -| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | -| `android` | Gradle build + tests | Non-docs, android changes | +| Job | Purpose | When it runs | +| ----------------- | ------------------------------------------------------- | ---------------------------------- | +| `docs-scope` | Detect docs-only changes | Always | +| `changed-scope` | Detect which areas changed (node/macos/android/windows) | Non-doc changes | +| `check` | TypeScript types, lint, format | Non-docs, node changes | +| `check-docs` | Markdown lint + broken link check | Docs changed | +| `secrets` | Detect leaked secrets | Always | +| `build-artifacts` | Build dist once, share with `release-check` | Pushes to `main`, node changes | +| `release-check` | Validate npm pack contents | Pushes to `main` after build | +| `checks` | Node tests + protocol check on PRs; Bun compat on push | Non-docs, node changes | +| `compat-node22` | Minimum supported Node runtime compatibility | Pushes to `main`, node changes | +| `checks-windows` | Windows-specific tests | Non-docs, windows-relevant changes | +| `macos` | Swift lint/build/test + TS tests | PRs with macos changes | +| `android` | Gradle build + tests | Non-docs, android changes | ## Fail-Fast Order Jobs are ordered so cheap checks fail before expensive ones run: -1. `docs-scope` + `code-analysis` + `check` (parallel, ~1-2 min) -2. `build-artifacts` (blocked on above) -3. `checks`, `checks-windows`, `macos`, `android` (blocked on build) +1. `docs-scope` + `changed-scope` + `check` + `secrets` (parallel, cheap gates first) +2. PRs: `checks` (Linux Node test split into 2 shards), `checks-windows`, `macos`, `android` +3. Pushes to `main`: `build-artifacts` + `release-check` + Bun compat + `compat-node22` Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. diff --git a/docs/cli/browser.md b/docs/cli/browser.md index 8e0ddad92ef..f9ddc151717 100644 --- a/docs/cli/browser.md +++ b/docs/cli/browser.md @@ -27,7 +27,7 @@ Related: ## Quick start (local) ```bash -openclaw browser --browser-profile chrome tabs +openclaw browser profiles openclaw browser --browser-profile openclaw start openclaw browser --browser-profile openclaw open https://example.com openclaw browser --browser-profile openclaw snapshot @@ -38,7 +38,8 @@ openclaw browser --browser-profile openclaw snapshot Profiles are named browser routing configs. In practice: - `openclaw`: launches/attaches to a dedicated OpenClaw-managed Chrome instance (isolated user data dir). -- `chrome`: controls your existing Chrome tab(s) via the Chrome extension relay. +- `user`: controls your existing signed-in Chrome session via Chrome DevTools MCP. +- `chrome-relay`: controls your existing Chrome tab(s) via the Chrome extension relay. ```bash openclaw browser profiles diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 95c20e3aa7c..96367774948 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -126,6 +126,23 @@ openclaw gateway probe openclaw gateway probe --json ``` +Interpretation: + +- `Reachable: yes` means at least one target accepted a WebSocket connect. +- `RPC: ok` means detail RPC calls (`health`/`status`/`system-presence`/`config.get`) also succeeded. +- `RPC: limited - missing scope: operator.read` means connect succeeded but detail RPC is scope-limited. This is reported as **degraded** reachability, not full failure. +- Exit code is non-zero only when no probed target is reachable. + +JSON notes (`--json`): + +- Top level: + - `ok`: at least one target is reachable. + - `degraded`: at least one target had scope-limited detail RPC. +- Per target (`targets[].connect`): + - `ok`: reachability after connect + degraded classification. + - `rpcOk`: full detail RPC success. + - `scopeLimited`: detail RPC failed due to missing operator scope. + #### Remote over SSH (Mac app parity) The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`. diff --git a/docs/cli/index.md b/docs/cli/index.md index cbcd5bff0b5..2796e7927d2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -337,7 +337,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) @@ -355,8 +355,8 @@ Options: - `--minimax-api-key ` - `--opencode-zen-api-key ` - `--opencode-go-api-key ` -- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key`) -- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key`) +- `--custom-base-url ` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`) +- `--custom-model-id ` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`) - `--custom-api-key ` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted) - `--custom-provider-id ` (non-interactive; optional custom provider id) - `--custom-compatibility ` (non-interactive; optional; default `openai`) diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 36629a3bb8d..4b30e0d52b3 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -43,6 +43,18 @@ openclaw onboard --non-interactive \ `--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. +Non-interactive Ollama: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --custom-base-url "http://ollama-host:11434" \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk +``` + +`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here. + Store provider keys as refs instead of plaintext: ```bash @@ -83,6 +95,13 @@ openclaw onboard --non-interactive \ --accept-risk ``` +Non-interactive local gateway health: + +- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully. +- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`. +- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`. +- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied. + Interactive onboarding behavior with reference mode: - Choose **Use secret reference** when prompted. diff --git a/docs/cli/qr.md b/docs/cli/qr.md index 2fc070ca1bd..1575b16d029 100644 --- a/docs/cli/qr.md +++ b/docs/cli/qr.md @@ -17,7 +17,7 @@ openclaw qr openclaw qr --setup-code-only openclaw qr --json openclaw qr --remote -openclaw qr --url wss://gateway.example/ws --token '' +openclaw qr --url wss://gateway.example/ws ``` ## Options @@ -25,8 +25,8 @@ openclaw qr --url wss://gateway.example/ws --token '' - `--remote`: use `gateway.remote.url` plus remote token/password from config - `--url `: override gateway URL used in payload - `--public-url `: override public URL used in payload -- `--token `: override gateway token for payload -- `--password `: override gateway password for payload +- `--token `: override which gateway token the bootstrap flow authenticates against +- `--password `: override which gateway password the bootstrap flow authenticates against - `--setup-code-only`: print only setup code - `--no-ascii`: skip ASCII QR rendering - `--json`: emit JSON (`setupCode`, `gatewayUrl`, `auth`, `urlSource`) @@ -34,6 +34,7 @@ openclaw qr --url wss://gateway.example/ws --token '' ## Notes - `--token` and `--password` are mutually exclusive. +- The setup code itself now carries an opaque short-lived `bootstrapToken`, not the shared gateway token/password. - With `--remote`, if effectively active remote credentials are configured as SecretRefs and you do not pass `--token` or `--password`, the command resolves them from the active gateway snapshot. If gateway is unavailable, the command fails fast. - Without `--remote`, local gateway auth SecretRefs are resolved when no CLI auth override is passed: - `gateway.auth.token` resolves when token auth can win (explicit `gateway.auth.mode="token"` or inferred mode where no password source wins). diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 4ed5ace54ee..6ea2df094f0 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -24,6 +24,12 @@ Scope selection: - `--all-agents`: aggregate all configured agent stores - `--store `: explicit store path (cannot be combined with `--agent` or `--all-agents`) +`openclaw sessions --all-agents` reads configured agent stores. Gateway and ACP +session discovery are broader: they also include disk-only stores found under +the default `agents/` root or a templated `session.store` root. Those +discovered stores must resolve to regular `sessions.json` files inside the +agent root; symlinks and out-of-root paths are skipped. + JSON examples: `openclaw sessions --all-agents --json`: @@ -54,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/model-providers.md b/docs/concepts/model-providers.md index 549875c77b4..a502240226e 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -47,6 +47,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Override per model via `agents.defaults.models["openai/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) - OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`) - OpenAI priority processing can be enabled via `agents.defaults.models["openai/"].params.serviceTier` +- OpenAI fast mode can be enabled per model via `agents.defaults.models["/"].params.fastMode` +- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because the live OpenAI API rejects it; Spark is treated as Codex-only ```json5 { @@ -61,6 +63,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override) - Example model: `anthropic/claude-opus-4-6` - CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic` +- Direct API-key models support the shared `/fast` toggle and `params.fastMode`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`) - Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance. - Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth. @@ -78,6 +81,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no** - CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex` - Default transport is `auto` (WebSocket-first, SSE fallback) - Override per model via `agents.defaults.models["openai-codex/"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`) +- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*` +- `openai-codex/gpt-5.3-codex-spark` remains available when the Codex OAuth catalog exposes it; entitlement-dependent - Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw. ```json5 @@ -352,7 +357,7 @@ See [/providers/minimax](/providers/minimax) for setup details, model options, a ### Ollama -Ollama is a local LLM runtime that provides an OpenAI-compatible API: +Ollama ships as a bundled provider plugin and uses Ollama's native API: - Provider: `ollama` - Auth: None required (local server) @@ -372,11 +377,15 @@ ollama pull llama3.3 } ``` -Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with `OLLAMA_API_KEY`, and `openclaw onboard` can configure it directly as a first-class provider. See [/providers/ollama](/providers/ollama) for onboarding, cloud/local mode, and custom configuration. +Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with +`OLLAMA_API_KEY`, and the bundled provider plugin adds Ollama directly to +`openclaw onboard` and the model picker. See [/providers/ollama](/providers/ollama) +for onboarding, cloud/local mode, and custom configuration. ### vLLM -vLLM is a local (or self-hosted) OpenAI-compatible server: +vLLM ships as a bundled provider plugin for local/self-hosted OpenAI-compatible +servers: - Provider: `vllm` - Auth: Optional (depends on your server) @@ -400,6 +409,34 @@ Then set a model (replace with one of the IDs returned by `/v1/models`): See [/providers/vllm](/providers/vllm) for details. +### SGLang + +SGLang ships as a bundled provider plugin for fast self-hosted +OpenAI-compatible servers: + +- Provider: `sglang` +- Auth: Optional (depends on your server) +- Default base URL: `http://127.0.0.1:30000/v1` + +To opt in to auto-discovery locally (any value works if your server does not +enforce auth): + +```bash +export SGLANG_API_KEY="sglang-local" +``` + +Then set a model (replace with one of the IDs returned by `/v1/models`): + +```json5 +{ + agents: { + defaults: { model: { primary: "sglang/your-model-id" } }, + }, +} +``` + +See [/providers/sglang](/providers/sglang) for details. + ### Local proxies (LM Studio, vLLM, LiteLLM, etc.) Example (OpenAI‑compatible): diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 6c9010d2c11..2f00325b730 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -191,16 +191,16 @@ 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. - Legacy `group:` keys are still recognized for migration. - Inbound contexts may still use `group:`; the channel is inferred from `Provider` and normalized to the canonical `agent:::group:` form. - Other sources: - - Cron jobs: `cron:` + - Cron jobs: `cron:` (isolated) or custom `session:` (persistent) - Webhooks: `hook:` (unless explicitly set by the hook) - Node runs: `node-` @@ -281,7 +281,7 @@ Runtime override (owner only): - `openclaw status` — shows store path and recent sessions. - `openclaw sessions --json` — dumps every entry (filter with `--active `). - `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access). -- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). +- Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/fast/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs). - Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors). - Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count). - Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction). 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/docs.json b/docs/docs.json index e6cf5ba382b..402d56aa380 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -876,6 +876,7 @@ "group": "Hosting and deployment", "pages": [ "vps", + "install/kubernetes", "install/fly", "install/hetzner", "install/gcp", diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index db5077aebcf..658a3084437 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2198,7 +2198,7 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi { id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 192000, @@ -2238,7 +2238,7 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on { id: "MiniMax-M2.5", name: "MiniMax M2.5", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 }, contextWindow: 200000, @@ -2342,7 +2342,7 @@ See [Plugins](/tools/plugin). browser: { enabled: true, evaluateEnabled: true, - defaultProfile: "chrome", + defaultProfile: "user", ssrfPolicy: { dangerouslyAllowPrivateNetwork: true, // default trusted-network mode // allowPrivateNetwork: true, // legacy alias @@ -2447,6 +2447,14 @@ See [Plugins](/tools/plugin). // Remove tools from the default HTTP deny list allow: ["gateway"], }, + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + timeoutMs: 10000, + }, + }, + }, }, } ``` @@ -2472,6 +2480,11 @@ See [Plugins](/tools/plugin). - `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`. - `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`: client-side break-glass override that allows plaintext `ws://` to trusted private-network IPs; default remains loopback-only for plaintext. - `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves. +- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build. +- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`. +- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration. +- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above. +- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS. - Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset. - If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking). - `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index ece612d101d..0f1dd65cbbc 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -225,6 +225,63 @@ When validation fails: + + Relay-backed push is configured in `openclaw.json`. + + Set this in gateway config: + + ```json5 + { + gateway: { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + // Optional. Default: 10000 + timeoutMs: 10000, + }, + }, + }, + }, + } + ``` + + CLI equivalent: + + ```bash + openclaw config set gateway.push.apns.relay.baseUrl https://relay.example.com + ``` + + What this does: + + - Lets the gateway send `push.test`, wake nudges, and reconnect wakes through the external relay. + - Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token. + - Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration. + - Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay. + - Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment. + + End-to-end flow: + + 1. Install an official/TestFlight iOS build that was compiled with the same relay base URL. + 2. Configure `gateway.push.apns.relay.baseUrl` on the gateway. + 3. Pair the iOS app to the gateway and let both node and operator sessions connect. + 4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway. + 5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes. + + Operational notes: + + - If you switch the iOS app to a different gateway, reconnect the app so it can publish a new relay registration bound to that gateway. + - If you ship a new iOS build that points at a different relay deployment, the app refreshes its cached relay registration instead of reusing the old relay origin. + + Compatibility note: + + - `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides. + - `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config. + + See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model. + + + ```json5 { @@ -415,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/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index bcba166db9d..fa86f912ef5 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -18,77 +18,16 @@ This endpoint is **disabled by default**. Enable it in config first. Under the hood, requests are executed as a normal Gateway agent run (same codepath as `openclaw agent`), so routing/permissions/config match your Gateway. -## Authentication +## Authentication, security, and routing -Uses the Gateway auth configuration. Send a bearer token: +Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api): -- `Authorization: Bearer ` +- use `Authorization: Bearer ` with the normal Gateway auth config +- treat the endpoint as full operator access for the gateway instance +- select agents with `model: "openclaw:"`, `model: "agent:"`, or `x-openclaw-agent-id` +- use `x-openclaw-session-key` for explicit session routing -Notes: - -- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). -- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). -- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. - -## Security boundary (important) - -Treat this endpoint as a **full operator-access** surface for the gateway instance. - -- HTTP bearer auth here is not a narrow per-user scope model. -- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. -- Requests run through the same control-plane agent path as trusted operator actions. -- There is no separate non-owner/per-user tool boundary on this endpoint; once a caller passes Gateway auth here, OpenClaw treats that caller as a trusted operator for this gateway. -- If the target agent policy allows sensitive tools, this endpoint can use them. -- Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. - -See [Security](/gateway/security) and [Remote access](/gateway/remote). - -## Choosing an agent - -No custom headers required: encode the agent id in the OpenResponses `model` field: - -- `model: "openclaw:"` (example: `"openclaw:main"`, `"openclaw:beta"`) -- `model: "agent:"` (alias) - -Or target a specific OpenClaw agent by header: - -- `x-openclaw-agent-id: ` (default: `main`) - -Advanced: - -- `x-openclaw-session-key: ` to fully control session routing. - -## Enabling the endpoint - -Set `gateway.http.endpoints.responses.enabled` to `true`: - -```json5 -{ - gateway: { - http: { - endpoints: { - responses: { enabled: true }, - }, - }, - }, -} -``` - -## Disabling the endpoint - -Set `gateway.http.endpoints.responses.enabled` to `false`: - -```json5 -{ - gateway: { - http: { - endpoints: { - responses: { enabled: false }, - }, - }, - }, -} -``` +Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`. ## Session behavior diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 3084adf82ad..f7f6583d794 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -304,6 +304,7 @@ schema: - `channels.googlechat.dangerouslyAllowNameMatching` - `channels.googlechat.accounts..dangerouslyAllowNameMatching` - `channels.msteams.dangerouslyAllowNameMatching` +- `channels.zalouser.dangerouslyAllowNameMatching` (extension channel) - `channels.irc.dangerouslyAllowNameMatching` (extension channel) - `channels.irc.accounts..dangerouslyAllowNameMatching` (extension channel) - `channels.mattermost.dangerouslyAllowNameMatching` (extension channel) diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index ebea28a6541..f5829454e57 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -289,7 +289,7 @@ Look for: - Valid browser executable path. - CDP profile reachability. -- Extension relay tab attachment for `profile="chrome"`. +- Extension relay tab attachment for `profile="chrome-relay"`. Common signatures: diff --git a/docs/help/faq.md b/docs/help/faq.md index 453688c1c5f..37f5f96c815 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -179,7 +179,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, - [I closed my terminal on Windows - how do I restart OpenClaw?](#i-closed-my-terminal-on-windows-how-do-i-restart-openclaw) - [The Gateway is up but replies never arrive. What should I check?](#the-gateway-is-up-but-replies-never-arrive-what-should-i-check) - ["Disconnected from gateway: no reason" - what now?](#disconnected-from-gateway-no-reason-what-now) - - [Telegram setMyCommands fails with network errors. What should I check?](#telegram-setmycommands-fails-with-network-errors-what-should-i-check) + - [Telegram setMyCommands fails. What should I check?](#telegram-setmycommands-fails-what-should-i-check) - [TUI shows no output. What should I check?](#tui-shows-no-output-what-should-i-check) - [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway) - [ELI5: `openclaw gateway restart` vs `openclaw gateway`](#eli5-openclaw-gateway-restart-vs-openclaw-gateway) @@ -2710,7 +2710,7 @@ openclaw logs --follow Docs: [Dashboard](/web/dashboard), [Remote access](/gateway/remote), [Troubleshooting](/gateway/troubleshooting). -### Telegram setMyCommands fails with network errors What should I check +### Telegram setMyCommands fails What should I check Start with logs and channel status: @@ -2719,7 +2719,11 @@ openclaw channels status openclaw channels logs --channel telegram ``` -If you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works. +Then match the error: + +- `BOT_COMMANDS_TOO_MUCH`: the Telegram menu has too many entries. OpenClaw already trims to the Telegram limit and retries with fewer commands, but some menu entries still need to be dropped. Reduce plugin/skill/custom commands, or disable `channels.telegram.commands.native` if you do not need the menu. +- `TypeError: fetch failed`, `Network request for 'setMyCommands' failed!`, or similar network errors: if you are on a VPS or behind a proxy, confirm outbound HTTPS is allowed and DNS works for `api.telegram.org`. + If the Gateway is remote, make sure you are looking at logs on the Gateway host. Docs: [Telegram](/channels/telegram), [Channel troubleshooting](/channels/troubleshooting). diff --git a/docs/help/testing.md b/docs/help/testing.md index db374bb03da..b2057e8a1da 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -53,8 +53,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - No real keys required - Should be fast and stable - Pool note: - - OpenClaw uses Vitest `vmForks` on Node 22/23 for faster unit shards. - - On Node 24+, OpenClaw automatically falls back to regular `forks` to avoid Node VM linking errors (`ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`). + - OpenClaw uses Vitest `vmForks` on Node 22, 23, and 24 for faster unit shards. + - On Node 25+, OpenClaw automatically falls back to regular `forks` until the repo is re-validated there. - Override manually with `OPENCLAW_TEST_VM_FORKS=0` (force `forks`) or `OPENCLAW_TEST_VM_FORKS=1` (force `vmForks`). ### E2E (gateway smoke) diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 951e1a480d7..a3988c4ea58 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -28,7 +28,7 @@ Good output in one line: - `openclaw status` → shows configured channels and no obvious auth errors. - `openclaw status --all` → full report is present and shareable. -- `openclaw gateway probe` → expected gateway target is reachable. +- `openclaw gateway probe` → expected gateway target is reachable (`Reachable: yes`). `RPC: limited - missing scope: operator.read` is degraded diagnostics, not a connect failure. - `openclaw gateway status` → `Runtime: running` and `RPC probe: ok`. - `openclaw doctor` → no blocking config/service errors. - `openclaw channels status --probe` → channels report `connected` or `ready`. diff --git a/docs/index.md b/docs/index.md index f838ebf4cab..7c69600f55d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps — - **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing - **Open source**: MIT licensed, community-driven -**What do you need?** Node 22+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available. +**What do you need?** Node 24 (recommended), or Node 22 LTS (`22.16+`) for compatibility, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available. ## How it works diff --git a/docs/install/ansible.md b/docs/install/ansible.md index be91aedaadd..63c18bec237 100644 --- a/docs/install/ansible.md +++ b/docs/install/ansible.md @@ -46,7 +46,7 @@ The Ansible playbook installs and configures: 1. **Tailscale** (mesh VPN for secure remote access) 2. **UFW firewall** (SSH + Tailscale ports only) 3. **Docker CE + Compose V2** (for agent sandboxes) -4. **Node.js 22.x + pnpm** (runtime dependencies) +4. **Node.js 24 + pnpm** (runtime dependencies; Node 22 LTS, currently `22.16+`, remains supported for compatibility) 5. **OpenClaw** (host-based, not containerized) 6. **Systemd service** (auto-start with security hardening) diff --git a/docs/install/bun.md b/docs/install/bun.md index 9b3dcb2c224..5cbe76ce3ac 100644 --- a/docs/install/bun.md +++ b/docs/install/bun.md @@ -45,7 +45,7 @@ bun run vitest run Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`). For this repo, the commonly blocked scripts are not required: -- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (we run Node 22+). +- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (OpenClaw defaults to Node 24 and still supports Node 22 LTS, currently `22.16+`). - `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts). If you hit a real runtime issue that requires these scripts, trust them explicitly: diff --git a/docs/install/docker-vm-runtime.md b/docs/install/docker-vm-runtime.md new file mode 100644 index 00000000000..77436f44486 --- /dev/null +++ b/docs/install/docker-vm-runtime.md @@ -0,0 +1,138 @@ +--- +summary: "Shared Docker VM runtime steps for long-lived OpenClaw Gateway hosts" +read_when: + - You are deploying OpenClaw on a cloud VM with Docker + - You need the shared binary bake, persistence, and update flow +title: "Docker VM Runtime" +--- + +# Docker VM Runtime + +Shared runtime steps for VM-based Docker installs such as GCP, Hetzner, and similar VPS providers. + +## Bake required binaries into the image + +Installing binaries inside a running container is a trap. +Anything installed at runtime will be lost on restart. + +All external binaries required by skills must be installed at image build time. + +The examples below show three common binaries only: + +- `gog` for Gmail access +- `goplaces` for Google Places +- `wacli` for WhatsApp + +These are examples, not a complete list. +You may install as many binaries as needed using the same pattern. + +If you add new skills later that depend on additional binaries, you must: + +1. Update the Dockerfile +2. Rebuild the image +3. Restart the containers + +**Example Dockerfile** + +```dockerfile +FROM node:24-bookworm + +RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/* + +# Example binary 1: Gmail CLI +RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog + +# Example binary 2: Google Places CLI +RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces + +# Example binary 3: WhatsApp CLI +RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli + +# Add more binaries below using the same pattern + +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +COPY ui/package.json ./ui/package.json +COPY scripts ./scripts + +RUN corepack enable +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm build +RUN pnpm ui:install +RUN pnpm ui:build + +ENV NODE_ENV=production + +CMD ["node","dist/index.js"] +``` + +## Build and launch + +```bash +docker compose build +docker compose up -d openclaw-gateway +``` + +If build fails with `Killed` or `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. +Use a larger machine class before retrying. + +Verify binaries: + +```bash +docker compose exec openclaw-gateway which gog +docker compose exec openclaw-gateway which goplaces +docker compose exec openclaw-gateway which wacli +``` + +Expected output: + +``` +/usr/local/bin/gog +/usr/local/bin/goplaces +/usr/local/bin/wacli +``` + +Verify Gateway: + +```bash +docker compose logs -f openclaw-gateway +``` + +Expected output: + +``` +[gateway] listening on ws://0.0.0.0:18789 +``` + +## What persists where + +OpenClaw runs in Docker, but Docker is not the source of truth. +All long-lived state must survive restarts, rebuilds, and reboots. + +| Component | Location | Persistence mechanism | Notes | +| ------------------- | --------------------------------- | ---------------------- | -------------------------------- | +| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, tokens | +| Model auth profiles | `/home/node/.openclaw/` | Host volume mount | OAuth tokens, API keys | +| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state | +| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts | +| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login | +| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` | +| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time | +| Node runtime | Container filesystem | Docker image | Rebuilt every image build | +| OS packages | Container filesystem | Docker image | Do not install at runtime | +| Docker container | Ephemeral | Restartable | Safe to destroy | + +## Updates + +To update OpenClaw on the VM: + +```bash +git pull +docker compose build +docker compose up -d +``` diff --git a/docs/install/docker.md b/docs/install/docker.md index c6337c3db48..a68066dcd57 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -165,13 +165,13 @@ Common tags: The main Docker image currently uses: -- `node:22-bookworm` +- `node:24-bookworm` The docker image now publishes OCI base-image annotations (sha256 is an example, and points at the pinned multi-arch manifest list for that tag): -- `org.opencontainers.image.base.name=docker.io/library/node:22-bookworm` -- `org.opencontainers.image.base.digest=sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9` +- `org.opencontainers.image.base.name=docker.io/library/node:24-bookworm` +- `org.opencontainers.image.base.digest=sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b` - `org.opencontainers.image.source=https://github.com/openclaw/openclaw` - `org.opencontainers.image.url=https://openclaw.ai` - `org.opencontainers.image.documentation=https://docs.openclaw.ai/install/docker` @@ -408,7 +408,7 @@ To speed up rebuilds, order your Dockerfile so dependency layers are cached. This avoids re-running `pnpm install` unless lockfiles change: ```dockerfile -FROM node:22-bookworm +FROM node:24-bookworm # Install Bun (required for build scripts) RUN curl -fsSL https://bun.sh/install | bash diff --git a/docs/install/gcp.md b/docs/install/gcp.md index 2c6bdd8ac1f..7ff4a00d087 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -281,77 +281,20 @@ services: --- -## 10) Bake required binaries into the image (critical) +## 10) Shared Docker VM runtime steps -Installing binaries inside a running container is a trap. -Anything installed at runtime will be lost on restart. +Use the shared runtime guide for the common Docker host flow: -All external binaries required by skills must be installed at image build time. - -The examples below show three common binaries only: - -- `gog` for Gmail access -- `goplaces` for Google Places -- `wacli` for WhatsApp - -These are examples, not a complete list. -You may install as many binaries as needed using the same pattern. - -If you add new skills later that depend on additional binaries, you must: - -1. Update the Dockerfile -2. Rebuild the image -3. Restart the containers - -**Example Dockerfile** - -```dockerfile -FROM node:22-bookworm - -RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/* - -# Example binary 1: Gmail CLI -RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \ - | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog - -# Example binary 2: Google Places CLI -RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \ - | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces - -# Example binary 3: WhatsApp CLI -RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \ - | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli - -# Add more binaries below using the same pattern - -WORKDIR /app -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY ui/package.json ./ui/package.json -COPY scripts ./scripts - -RUN corepack enable -RUN pnpm install --frozen-lockfile - -COPY . . -RUN pnpm build -RUN pnpm ui:install -RUN pnpm ui:build - -ENV NODE_ENV=production - -CMD ["node","dist/index.js"] -``` +- [Bake required binaries into the image](/install/docker-vm-runtime#bake-required-binaries-into-the-image) +- [Build and launch](/install/docker-vm-runtime#build-and-launch) +- [What persists where](/install/docker-vm-runtime#what-persists-where) +- [Updates](/install/docker-vm-runtime#updates) --- -## 11) Build and launch +## 11) GCP-specific launch notes -```bash -docker compose build -docker compose up -d openclaw-gateway -``` - -If build fails with `Killed` / `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds. +On GCP, if build fails with `Killed` or `exit code 137` during `pnpm install --frozen-lockfile`, the VM is out of memory. Use `e2-small` minimum, or `e2-medium` for more reliable first builds. When binding to LAN (`OPENCLAW_GATEWAY_BIND=lan`), configure a trusted browser origin before continuing: @@ -361,39 +304,7 @@ docker compose run --rm openclaw-cli config set gateway.controlUi.allowedOrigins If you changed the gateway port, replace `18789` with your configured port. -Verify binaries: - -```bash -docker compose exec openclaw-gateway which gog -docker compose exec openclaw-gateway which goplaces -docker compose exec openclaw-gateway which wacli -``` - -Expected output: - -``` -/usr/local/bin/gog -/usr/local/bin/goplaces -/usr/local/bin/wacli -``` - ---- - -## 12) Verify Gateway - -```bash -docker compose logs -f openclaw-gateway -``` - -Success: - -``` -[gateway] listening on ws://0.0.0.0:18789 -``` - ---- - -## 13) Access from your laptop +## 12) Access from your laptop Create an SSH tunnel to forward the Gateway port: @@ -420,38 +331,8 @@ docker compose run --rm openclaw-cli devices list docker compose run --rm openclaw-cli devices approve ``` ---- - -## What persists where (source of truth) - -OpenClaw runs in Docker, but Docker is not the source of truth. -All long-lived state must survive restarts, rebuilds, and reboots. - -| Component | Location | Persistence mechanism | Notes | -| ------------------- | --------------------------------- | ---------------------- | -------------------------------- | -| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, tokens | -| Model auth profiles | `/home/node/.openclaw/` | Host volume mount | OAuth tokens, API keys | -| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state | -| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts | -| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login | -| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` | -| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time | -| Node runtime | Container filesystem | Docker image | Rebuilt every image build | -| OS packages | Container filesystem | Docker image | Do not install at runtime | -| Docker container | Ephemeral | Restartable | Safe to destroy | - ---- - -## Updates - -To update OpenClaw on the VM: - -```bash -cd ~/openclaw -git pull -docker compose build -docker compose up -d -``` +Need the shared persistence and update reference again? +See [Docker VM Runtime](/install/docker-vm-runtime#what-persists-where) and [Docker VM Runtime updates](/install/docker-vm-runtime#updates). --- diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md index 9baf90278b8..46bc76d6243 100644 --- a/docs/install/hetzner.md +++ b/docs/install/hetzner.md @@ -202,107 +202,20 @@ services: --- -## 7) Bake required binaries into the image (critical) +## 7) Shared Docker VM runtime steps -Installing binaries inside a running container is a trap. -Anything installed at runtime will be lost on restart. +Use the shared runtime guide for the common Docker host flow: -All external binaries required by skills must be installed at image build time. - -The examples below show three common binaries only: - -- `gog` for Gmail access -- `goplaces` for Google Places -- `wacli` for WhatsApp - -These are examples, not a complete list. -You may install as many binaries as needed using the same pattern. - -If you add new skills later that depend on additional binaries, you must: - -1. Update the Dockerfile -2. Rebuild the image -3. Restart the containers - -**Example Dockerfile** - -```dockerfile -FROM node:22-bookworm - -RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/* - -# Example binary 1: Gmail CLI -RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \ - | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog - -# Example binary 2: Google Places CLI -RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \ - | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces - -# Example binary 3: WhatsApp CLI -RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \ - | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli - -# Add more binaries below using the same pattern - -WORKDIR /app -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ -COPY ui/package.json ./ui/package.json -COPY scripts ./scripts - -RUN corepack enable -RUN pnpm install --frozen-lockfile - -COPY . . -RUN pnpm build -RUN pnpm ui:install -RUN pnpm ui:build - -ENV NODE_ENV=production - -CMD ["node","dist/index.js"] -``` +- [Bake required binaries into the image](/install/docker-vm-runtime#bake-required-binaries-into-the-image) +- [Build and launch](/install/docker-vm-runtime#build-and-launch) +- [What persists where](/install/docker-vm-runtime#what-persists-where) +- [Updates](/install/docker-vm-runtime#updates) --- -## 8) Build and launch +## 8) Hetzner-specific access -```bash -docker compose build -docker compose up -d openclaw-gateway -``` - -Verify binaries: - -```bash -docker compose exec openclaw-gateway which gog -docker compose exec openclaw-gateway which goplaces -docker compose exec openclaw-gateway which wacli -``` - -Expected output: - -``` -/usr/local/bin/gog -/usr/local/bin/goplaces -/usr/local/bin/wacli -``` - ---- - -## 9) Verify Gateway - -```bash -docker compose logs -f openclaw-gateway -``` - -Success: - -``` -[gateway] listening on ws://0.0.0.0:18789 -``` - -From your laptop: +After the shared build and launch steps, tunnel from your laptop: ```bash ssh -N -L 18789:127.0.0.1:18789 root@YOUR_VPS_IP @@ -316,25 +229,7 @@ Paste your gateway token. --- -## What persists where (source of truth) - -OpenClaw runs in Docker, but Docker is not the source of truth. -All long-lived state must survive restarts, rebuilds, and reboots. - -| Component | Location | Persistence mechanism | Notes | -| ------------------- | --------------------------------- | ---------------------- | -------------------------------- | -| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, tokens | -| Model auth profiles | `/home/node/.openclaw/` | Host volume mount | OAuth tokens, API keys | -| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state | -| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts | -| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login | -| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` | -| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time | -| Node runtime | Container filesystem | Docker image | Rebuilt every image build | -| OS packages | Container filesystem | Docker image | Do not install at runtime | -| Docker container | Ephemeral | Restartable | Safe to destroy | - ---- +The shared persistence map lives in [Docker VM Runtime](/install/docker-vm-runtime#what-persists-where). ## Infrastructure as Code (Terraform) diff --git a/docs/install/index.md b/docs/install/index.md index 285324ed6b7..d0f847838d0 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -13,7 +13,7 @@ Already followed [Getting Started](/start/getting-started)? You're all set — t ## System requirements -- **[Node 22+](/install/node)** (the [installer script](#install-methods) will install it if missing) +- **[Node 24 (recommended)](/install/node)** (Node 22 LTS, currently `22.16+`, is still supported for compatibility; the [installer script](#install-methods) will install Node 24 if missing) - macOS, Linux, or Windows - `pnpm` only if you build from source @@ -70,7 +70,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl - If you already have Node 22+ and prefer to manage the install yourself: + If you already manage Node yourself, we recommend Node 24. OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility: diff --git a/docs/install/installer.md b/docs/install/installer.md index 78334681ad4..6317e8e06cc 100644 --- a/docs/install/installer.md +++ b/docs/install/installer.md @@ -70,8 +70,8 @@ Recommended for most interactive installs on macOS/Linux/WSL. Supports macOS and Linux (including WSL). If macOS is detected, installs Homebrew if missing. - - Checks Node version and installs Node 22 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). + + Checks Node version and installs Node 24 if needed (Homebrew on macOS, NodeSource setup scripts on Linux apt/dnf/yum). OpenClaw still supports Node 22 LTS, currently `22.16+`, for compatibility. Installs Git if missing. @@ -175,7 +175,7 @@ Designed for environments where you want everything under a local prefix (defaul - Downloads Node tarball (default `22.22.0`) to `/tools/node-v` and verifies SHA-256. + Downloads a pinned supported Node tarball (currently default `22.22.0`) to `/tools/node-v` and verifies SHA-256. If Git is missing, attempts install via apt/dnf/yum on Linux or Homebrew on macOS. @@ -251,8 +251,8 @@ Designed for environments where you want everything under a local prefix (defaul Requires PowerShell 5+. - - If missing, attempts install via winget, then Chocolatey, then Scoop. + + If missing, attempts install via winget, then Chocolatey, then Scoop. Node 22 LTS, currently `22.16+`, remains supported for compatibility. - `npm` method (default): global npm install using selected `-Tag` diff --git a/docs/install/kubernetes.md b/docs/install/kubernetes.md new file mode 100644 index 00000000000..577ff9d2df5 --- /dev/null +++ b/docs/install/kubernetes.md @@ -0,0 +1,191 @@ +--- +summary: "Deploy OpenClaw Gateway to a Kubernetes cluster with Kustomize" +read_when: + - You want to run OpenClaw on a Kubernetes cluster + - You want to test OpenClaw in a Kubernetes environment +title: "Kubernetes" +--- + +# OpenClaw on Kubernetes + +A minimal starting point for running OpenClaw on Kubernetes — not a production-ready deployment. It covers the core resources and is meant to be adapted to your environment. + +## Why not Helm? + +OpenClaw is a single container with some config files. The interesting customization is in agent content (markdown files, skills, config overrides), not infrastructure templating. Kustomize handles overlays without the overhead of a Helm chart. If your deployment grows more complex, a Helm chart can be layered on top of these manifests. + +## What you need + +- A running Kubernetes cluster (AKS, EKS, GKE, k3s, kind, OpenShift, etc.) +- `kubectl` connected to your cluster +- An API key for at least one model provider + +## Quick start + +```bash +# Replace with your provider: ANTHROPIC, GEMINI, OPENAI, or OPENROUTER +export _API_KEY="..." +./scripts/k8s/deploy.sh + +kubectl port-forward svc/openclaw 18789:18789 -n openclaw +open http://localhost:18789 +``` + +Retrieve the gateway token and paste it into the Control UI: + +```bash +kubectl get secret openclaw-secrets -n openclaw -o jsonpath='{.data.OPENCLAW_GATEWAY_TOKEN}' | base64 -d +``` + +For local debugging, `./scripts/k8s/deploy.sh --show-token` prints the token after deploy. + +## Local testing with Kind + +If you don't have a cluster, create one locally with [Kind](https://kind.sigs.k8s.io/): + +```bash +./scripts/k8s/create-kind.sh # auto-detects docker or podman +./scripts/k8s/create-kind.sh --delete # tear down +``` + +Then deploy as usual with `./scripts/k8s/deploy.sh`. + +## Step by step + +### 1) Deploy + +**Option A** — API key in environment (one step): + +```bash +# Replace with your provider: ANTHROPIC, GEMINI, OPENAI, or OPENROUTER +export _API_KEY="..." +./scripts/k8s/deploy.sh +``` + +The script creates a Kubernetes Secret with the API key and an auto-generated gateway token, then deploys. If the Secret already exists, it preserves the current gateway token and any provider keys not being changed. + +**Option B** — create the secret separately: + +```bash +export _API_KEY="..." +./scripts/k8s/deploy.sh --create-secret +./scripts/k8s/deploy.sh +``` + +Use `--show-token` with either command if you want the token printed to stdout for local testing. + +### 2) Access the gateway + +```bash +kubectl port-forward svc/openclaw 18789:18789 -n openclaw +open http://localhost:18789 +``` + +## What gets deployed + +``` +Namespace: openclaw (configurable via OPENCLAW_NAMESPACE) +├── Deployment/openclaw # Single pod, init container + gateway +├── Service/openclaw # ClusterIP on port 18789 +├── PersistentVolumeClaim # 10Gi for agent state and config +├── ConfigMap/openclaw-config # openclaw.json + AGENTS.md +└── Secret/openclaw-secrets # Gateway token + API keys +``` + +## Customization + +### Agent instructions + +Edit the `AGENTS.md` in `scripts/k8s/manifests/configmap.yaml` and redeploy: + +```bash +./scripts/k8s/deploy.sh +``` + +### Gateway config + +Edit `openclaw.json` in `scripts/k8s/manifests/configmap.yaml`. See [Gateway configuration](/gateway/configuration) for the full reference. + +### Add providers + +Re-run with additional keys exported: + +```bash +export ANTHROPIC_API_KEY="..." +export OPENAI_API_KEY="..." +./scripts/k8s/deploy.sh --create-secret +./scripts/k8s/deploy.sh +``` + +Existing provider keys stay in the Secret unless you overwrite them. + +Or patch the Secret directly: + +```bash +kubectl patch secret openclaw-secrets -n openclaw \ + -p '{"stringData":{"_API_KEY":"..."}}' +kubectl rollout restart deployment/openclaw -n openclaw +``` + +### Custom namespace + +```bash +OPENCLAW_NAMESPACE=my-namespace ./scripts/k8s/deploy.sh +``` + +### Custom image + +Edit the `image` field in `scripts/k8s/manifests/deployment.yaml`: + +```yaml +image: ghcr.io/openclaw/openclaw:2026.3.1 +``` + +### Expose beyond port-forward + +The default manifests bind the gateway to loopback inside the pod. That works with `kubectl port-forward`, but it does not work with a Kubernetes `Service` or Ingress path that needs to reach the pod IP. + +If you want to expose the gateway through an Ingress or load balancer: + +- Change the gateway bind in `scripts/k8s/manifests/configmap.yaml` from `loopback` to a non-loopback bind that matches your deployment model +- Keep gateway auth enabled and use a proper TLS-terminated entrypoint +- Configure the Control UI for remote access using the supported web security model (for example HTTPS/Tailscale Serve and explicit allowed origins when needed) + +## Re-deploy + +```bash +./scripts/k8s/deploy.sh +``` + +This applies all manifests and restarts the pod to pick up any config or secret changes. + +## Teardown + +```bash +./scripts/k8s/deploy.sh --delete +``` + +This deletes the namespace and all resources in it, including the PVC. + +## Architecture notes + +- The gateway binds to loopback inside the pod by default, so the included setup is for `kubectl port-forward` +- No cluster-scoped resources — everything lives in a single namespace +- Security: `readOnlyRootFilesystem`, `drop: ALL` capabilities, non-root user (UID 1000) +- The default config keeps the Control UI on the safer local-access path: loopback bind plus `kubectl port-forward` to `http://127.0.0.1:18789` +- If you move beyond localhost access, use the supported remote model: HTTPS/Tailscale plus the appropriate gateway bind and Control UI origin settings +- Secrets are generated in a temp directory and applied directly to the cluster — no secret material is written to the repo checkout + +## File structure + +``` +scripts/k8s/ +├── deploy.sh # Creates namespace + secret, deploys via kustomize +├── create-kind.sh # Local Kind cluster (auto-detects docker/podman) +└── manifests/ + ├── kustomization.yaml # Kustomize base + ├── configmap.yaml # openclaw.json + AGENTS.md + ├── deployment.yaml # Pod spec with security hardening + ├── pvc.yaml # 10Gi persistent storage + └── service.yaml # ClusterIP on 18789 +``` diff --git a/docs/install/node.md b/docs/install/node.md index 8c57fde4f72..9cf2f59ec77 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -9,7 +9,7 @@ read_when: # Node.js -OpenClaw requires **Node 22 or newer**. The [installer script](/install#install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs). +OpenClaw requires **Node 22.16 or newer**. **Node 24 is the default and recommended runtime** for installs, CI, and release workflows. Node 22 remains supported via the active LTS line. The [installer script](/install#install-methods) will detect and install Node automatically — this page is for when you want to set up Node yourself and make sure everything is wired up correctly (versions, PATH, global installs). ## Check your version @@ -17,7 +17,7 @@ OpenClaw requires **Node 22 or newer**. The [installer script](/install#install- node -v ``` -If this prints `v22.x.x` or higher, you're good. If Node isn't installed or the version is too old, pick an install method below. +If this prints `v24.x.x` or higher, you're on the recommended default. If it prints `v22.16.x` or higher, you're on the supported Node 22 LTS path, but we still recommend upgrading to Node 24 when convenient. If Node isn't installed or the version is too old, pick an install method below. ## Install Node @@ -36,7 +36,7 @@ If this prints `v22.x.x` or higher, you're good. If Node isn't installed or the **Ubuntu / Debian:** ```bash - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - sudo apt-get install -y nodejs ``` @@ -77,8 +77,8 @@ If this prints `v22.x.x` or higher, you're good. If Node isn't installed or the Example with fnm: ```bash -fnm install 22 -fnm use 22 +fnm install 24 +fnm use 24 ``` diff --git a/docs/platforms/android.md b/docs/platforms/android.md index 4df71b83e73..6bd5effb361 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -9,6 +9,8 @@ title: "Android App" # Android App (Node) +> **Note:** The Android app has not been publicly released yet. The source code is available in the [OpenClaw repository](https://github.com/openclaw/openclaw) under `apps/android`. You can build it yourself using Java 17 and the Android SDK (`./gradlew :app:assembleDebug`). See [apps/android/README.md](https://github.com/openclaw/openclaw/blob/main/apps/android/README.md) for build instructions. + ## Support snapshot - Role: companion node app (Android does not host the Gateway). diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index bddc63b9d1f..cd05587ae76 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -66,8 +66,8 @@ ssh root@YOUR_DROPLET_IP # Update system apt update && apt upgrade -y -# Install Node.js 22 -curl -fsSL https://deb.nodesource.com/setup_22.x | bash - +# Install Node.js 24 +curl -fsSL https://deb.nodesource.com/setup_24.x | bash - apt install -y nodejs # Install OpenClaw diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index 0a2eb5abae5..f64eba3fed0 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -49,6 +49,114 @@ openclaw nodes status openclaw gateway call node.list --params "{}" ``` +## Relay-backed push for official builds + +Official distributed iOS builds use the external push relay instead of publishing the raw APNs +token to the gateway. + +Gateway-side requirement: + +```json5 +{ + gateway: { + push: { + apns: { + relay: { + baseUrl: "https://relay.example.com", + }, + }, + }, + }, +} +``` + +How the flow works: + +- The iOS app registers with the relay using App Attest and the app receipt. +- The relay returns an opaque relay handle plus a registration-scoped send grant. +- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway. +- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`. +- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges. +- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build. +- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding. + +What the gateway does **not** need for this path: + +- No deployment-wide relay token. +- No direct APNs key for official/TestFlight relay-backed sends. + +Expected operator flow: + +1. Install the official/TestFlight iOS build. +2. Set `gateway.push.apns.relay.baseUrl` on the gateway. +3. Pair the app to the gateway and let it finish connecting. +4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds. +5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration. + +Compatibility note: + +- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway. + +## Authentication and trust flow + +The relay exists to enforce two constraints that direct APNs-on-gateway cannot provide for +official iOS builds: + +- Only genuine OpenClaw iOS builds distributed through Apple can use the hosted relay. +- A gateway can send relay-backed pushes only for iOS devices that paired with that specific + gateway. + +Hop by hop: + +1. `iOS app -> gateway` + - The app first pairs with the gateway through the normal Gateway auth flow. + - That gives the app an authenticated node session plus an authenticated operator session. + - The operator session is used to call `gateway.identity.get`. + +2. `iOS app -> relay` + - The app calls the relay registration endpoints over HTTPS. + - Registration includes App Attest proof plus the app receipt. + - The relay validates the bundle ID, App Attest proof, and Apple receipt, and requires the + official/production distribution path. + - This is what blocks local Xcode/dev builds from using the hosted relay. A local build may be + signed, but it does not satisfy the official Apple distribution proof the relay expects. + +3. `gateway identity delegation` + - Before relay registration, the app fetches the paired gateway identity from + `gateway.identity.get`. + - The app includes that gateway identity in the relay registration payload. + - The relay returns a relay handle and a registration-scoped send grant that are delegated to + that gateway identity. + +4. `gateway -> relay` + - The gateway stores the relay handle and send grant from `push.apns.register`. + - On `push.test`, reconnect wakes, and wake nudges, the gateway signs the send request with its + own device identity. + - The relay verifies both the stored send grant and the gateway signature against the delegated + gateway identity from registration. + - Another gateway cannot reuse that stored registration, even if it somehow obtains the handle. + +5. `relay -> APNs` + - The relay owns the production APNs credentials and the raw APNs token for the official build. + - The gateway never stores the raw APNs token for relay-backed official builds. + - The relay sends the final push to APNs on behalf of the paired gateway. + +Why this design was created: + +- To keep production APNs credentials out of user gateways. +- To avoid storing raw official-build APNs tokens on the gateway. +- To allow hosted relay usage only for official/TestFlight OpenClaw builds. +- To prevent one gateway from sending wake pushes to iOS devices owned by a different gateway. + +Local/manual builds remain on direct APNs. If you are testing those builds without the relay, the +gateway still needs direct APNs credentials: + +```bash +export OPENCLAW_APNS_TEAM_ID="TEAMID" +export OPENCLAW_APNS_KEY_ID="KEYID" +export OPENCLAW_APNS_PRIVATE_KEY_P8="$(cat /path/to/AuthKey_KEYID.p8)" +``` + ## Discovery paths ### Bonjour (LAN) diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 0cce3a54e75..c03dba6f795 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -15,7 +15,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t ## Beginner quick path (VPS) -1. Install Node 22+ +1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility) 2. `npm i -g openclaw@latest` 3. `openclaw onboard --install-daemon` 4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 @` diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 6cb878015fb..e6e57cc1809 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -16,7 +16,7 @@ running (or attaches to an existing local Gateway if one is already running). ## Install the CLI (required for local mode) -You need Node 22+ on the Mac, then install `openclaw` globally: +Node 24 is the default runtime on the Mac. Node 22 LTS, currently `22.16+`, still works for compatibility. Then install `openclaw` globally: ```bash npm install -g openclaw@ diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index e50a850086a..982f687049c 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -14,7 +14,7 @@ This guide covers the necessary steps to build and run the OpenClaw macOS applic Before building the app, ensure you have the following installed: 1. **Xcode 26.2+**: Required for Swift development. -2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts. +2. **Node.js 24 & pnpm**: Recommended for the gateway, CLI, and packaging scripts. Node 22 LTS, currently `22.16+`, remains supported for compatibility. ## 1. Install Dependencies diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index cd4052ac9dc..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.11 \ +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.11.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.11.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.11.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \ BUNDLE_ID=ai.openclaw.mac \ -APP_VERSION=2026.3.11 \ +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.11.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.11.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.11.zip` (and `OpenClaw-2026.3.11.dSYM.zip`) to the GitHub release for tag `v2026.3.11`. +- 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/mac/signing.md b/docs/platforms/mac/signing.md index 9927ca5f82b..0feac8cd281 100644 --- a/docs/platforms/mac/signing.md +++ b/docs/platforms/mac/signing.md @@ -14,7 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com - calls [`scripts/codesign-mac-app.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/codesign-mac-app.sh) to sign the main binary and app bundle so macOS treats each rebuild as the same signed bundle and keeps TCC permissions (notifications, accessibility, screen recording, mic, speech). For stable permissions, use a real signing identity; ad-hoc is opt-in and fragile (see [macOS permissions](/platforms/mac/permissions)). - uses `CODESIGN_TIMESTAMP=auto` by default; it enables trusted timestamps for Developer ID signatures. Set `CODESIGN_TIMESTAMP=off` to skip timestamping (offline debug builds). - inject build metadata into Info.plist: `OpenClawBuildTimestamp` (UTC) and `OpenClawGitCommit` (short hash) so the About pane can show build, git, and debug/release channel. -- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build. +- **Packaging defaults to Node 24**: the script runs TS builds and the Control UI build. Node 22 LTS, currently `22.16+`, remains supported for compatibility. - reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing). - runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass. diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 247bf757b91..5e7e35c9544 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -76,15 +76,15 @@ sudo apt install -y git curl build-essential sudo timedatectl set-timezone America/Chicago # Change to your timezone ``` -## 4) Install Node.js 22 (ARM64) +## 4) Install Node.js 24 (ARM64) ```bash # Install Node.js via NodeSource -curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - +curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - sudo apt install -y nodejs # Verify -node --version # Should show v22.x.x +node --version # Should show v24.x.x npm --version ``` diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index 3ab668ea01e..e40d798604d 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -22,6 +22,44 @@ Native Windows companion apps are planned. - [Install & updates](/install/updating) - Official WSL2 guide (Microsoft): [https://learn.microsoft.com/windows/wsl/install](https://learn.microsoft.com/windows/wsl/install) +## Native Windows status + +Native Windows CLI flows are improving, but WSL2 is still the recommended path. + +What works well on native Windows today: + +- website installer via `install.ps1` +- local CLI use such as `openclaw --version`, `openclaw doctor`, and `openclaw plugins list --json` +- embedded local-agent/provider smoke such as: + +```powershell +openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOWS-HATCH-OK." +``` + +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: + +```powershell +openclaw onboard --non-interactive --skip-health +openclaw gateway run +``` + +If you do want managed startup on native Windows: + +```powershell +openclaw gateway install +openclaw gateway status --json +``` + +If Scheduled Task creation is blocked, the fallback service mode still auto-starts after login through the current user's Startup folder. + ## Gateway - [Gateway runbook](/gateway) diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 17263ca0509..14198fdba36 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -296,6 +296,12 @@ Inbound policy defaults to `disabled`. To enable inbound calls, set: } ``` +`inboundPolicy: "allowlist"` is a low-assurance caller-ID screen. The plugin +normalizes the provider-supplied `From` value and compares it to `allowFrom`. +Webhook verification authenticates provider delivery and payload integrity, but +it does not prove PSTN/VoIP caller-number ownership. Treat `allowFrom` as +caller-ID filtering, not strong caller identity. + Auto-responses use the agent system. Tune with: - `responseModel` diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index de974315273..8974bb2dd61 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -44,6 +44,34 @@ openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY" - [Adaptive thinking](https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking) - [Extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking) +## Fast mode (Anthropic API) + +OpenClaw's shared `/fast` toggle also supports direct Anthropic API-key traffic. + +- `/fast on` maps to `service_tier: "auto"` +- `/fast off` maps to `service_tier: "standard_only"` +- Config default: + +```json5 +{ + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-5": { + params: { fastMode: true }, + }, + }, + }, + }, +} +``` + +Important limits: + +- This is **API-key only**. Anthropic setup-token / OAuth auth does not honor OpenClaw fast-mode tier injection. +- OpenClaw only injects Anthropic service tiers for direct `api.anthropic.com` requests. If you route `anthropic/*` through a proxy or gateway, `/fast` leaves `service_tier` untouched. +- Anthropic reports the effective tier on the response under `usage.service_tier`. On accounts without Priority Tier capacity, `service_tier: "auto"` may still resolve to `standard`. + ## Prompt caching (Anthropic API) OpenClaw supports Anthropic's prompt caching feature. This is **API-only**; subscription auth does not honor cache settings. diff --git a/docs/providers/glm.md b/docs/providers/glm.md index f65ea81f9da..64fe39a42df 100644 --- a/docs/providers/glm.md +++ b/docs/providers/glm.md @@ -14,7 +14,17 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`. ## CLI setup ```bash -openclaw onboard --auth-choice zai-api-key +# Coding Plan Global, recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-global + +# Coding Plan CN (China region), recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-cn + +# General API +openclaw onboard --auth-choice zai-global + +# General API CN (China region) +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/providers/index.md b/docs/providers/index.md index 50e45c6559b..f68cd0e0b53 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -37,7 +37,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi - [Mistral](/providers/mistral) - [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) - [NVIDIA](/providers/nvidia) -- [Ollama (local models)](/providers/ollama) +- [Ollama (cloud + local models)](/providers/ollama) - [OpenAI (API + Codex)](/providers/openai) - [OpenCode (Zen + Go)](/providers/opencode) - [OpenRouter](/providers/openrouter) diff --git a/docs/providers/minimax.md b/docs/providers/minimax.md index f060c637de8..8cdc5b028f6 100644 --- a/docs/providers/minimax.md +++ b/docs/providers/minimax.md @@ -151,7 +151,7 @@ Configure manually via `openclaw.json`: { id: "minimax-m2.5-gs32", name: "MiniMax M2.5 GS32", - reasoning: false, + reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 196608, diff --git a/docs/providers/ollama.md b/docs/providers/ollama.md index abc41361ed0..c4604a8e350 100644 --- a/docs/providers/ollama.md +++ b/docs/providers/ollama.md @@ -1,7 +1,7 @@ --- -summary: "Run OpenClaw with Ollama (local LLM runtime)" +summary: "Run OpenClaw with Ollama (cloud and local models)" read_when: - - You want to run OpenClaw with local models via Ollama + - You want to run OpenClaw with cloud or local models via Ollama - You need Ollama setup and configuration guidance title: "Ollama" --- @@ -16,6 +16,42 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo ## Quick start +### Onboarding wizard (recommended) + +The fastest way to set up Ollama is through the onboarding wizard: + +```bash +openclaw onboard +``` + +Select **Ollama** from the provider list. The wizard will: + +1. Ask for the Ollama base URL where your instance can be reached (default `http://127.0.0.1:11434`). +2. Let you choose **Cloud + Local** (cloud models and local models) or **Local** (local models only). +3. Open a browser sign-in flow if you choose **Cloud + Local** and are not signed in to ollama.com. +4. Discover available models and suggest defaults. +5. Auto-pull the selected model if it is not available locally. + +Non-interactive mode is also supported: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --accept-risk +``` + +Optionally specify a custom base URL or model: + +```bash +openclaw onboard --non-interactive \ + --auth-choice ollama \ + --custom-base-url "http://ollama-host:11434" \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk +``` + +### Manual setup + 1. Install Ollama: [https://ollama.com/download](https://ollama.com/download) 2. Pull a local model if you want local inference: @@ -28,7 +64,7 @@ ollama pull gpt-oss:20b ollama pull llama3.3 ``` -3. If you want Ollama Cloud models too, sign in: +3. If you want cloud models too, sign in: ```bash ollama signin @@ -41,7 +77,7 @@ openclaw onboard ``` - `Local`: local models only -- `Cloud + Local`: local models plus Ollama Cloud models +- `Cloud + Local`: local models plus cloud models - Cloud models such as `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, and `glm-5:cloud` do **not** require a local `ollama pull` OpenClaw currently suggests: @@ -191,6 +227,14 @@ Once configured, all your Ollama models are available: } ``` +## Cloud models + +Cloud models let you run cloud-hosted models (for example `kimi-k2.5:cloud`, `minimax-m2.5:cloud`, `glm-5:cloud`) alongside your local models. + +To use cloud models, select **Cloud + Local** mode during onboarding. The wizard checks whether you are signed in and opens a browser sign-in flow when needed. If authentication cannot be verified, the wizard falls back to local model defaults. + +You can also sign in directly at [ollama.com/signin](https://ollama.com/signin). + ## Advanced ### Reasoning models diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 4683f061546..a6a60f8f2ea 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -36,6 +36,12 @@ openclaw onboard --openai-api-key "$OPENAI_API_KEY" OpenAI's current API model docs list `gpt-5.4` and `gpt-5.4-pro` for direct OpenAI API usage. OpenClaw forwards both through the `openai/*` Responses path. +OpenClaw intentionally suppresses the stale `openai/gpt-5.3-codex-spark` row, +because direct OpenAI API calls reject it in live traffic. + +OpenClaw does **not** expose `openai/gpt-5.3-codex-spark` on the direct OpenAI +API path. `pi-ai` still ships a built-in row for that model, but live OpenAI API +requests currently reject it. Spark is treated as Codex-only in OpenClaw. ## Option B: OpenAI Code (Codex) subscription @@ -63,6 +69,18 @@ openclaw models auth login --provider openai-codex OpenAI's current Codex docs list `gpt-5.4` as the current Codex model. OpenClaw maps that to `openai-codex/gpt-5.4` for ChatGPT/Codex OAuth usage. +If your Codex account is entitled to Codex Spark, OpenClaw also supports: + +- `openai-codex/gpt-5.3-codex-spark` + +OpenClaw treats Codex Spark as Codex-only. It does not expose a direct +`openai/gpt-5.3-codex-spark` API-key path. + +OpenClaw also preserves `openai-codex/gpt-5.3-codex-spark` when `pi-ai` +discovers it. Treat it as entitlement-dependent and experimental: Codex Spark is +separate from GPT-5.4 `/fast`, and availability depends on the signed-in Codex / +ChatGPT account. + ### Transport default OpenClaw uses `pi-ai` for model streaming. For both `openai/*` and @@ -165,6 +183,46 @@ pass that field through on direct `openai/*` Responses requests. Supported values are `auto`, `default`, `flex`, and `priority`. +### OpenAI fast mode + +OpenClaw exposes a shared fast-mode toggle for both `openai/*` and +`openai-codex/*` sessions: + +- Chat/UI: `/fast status|on|off` +- Config: `agents.defaults.models["/"].params.fastMode` + +When fast mode is enabled, OpenClaw applies a low-latency OpenAI profile: + +- `reasoning.effort = "low"` when the payload does not already specify reasoning +- `text.verbosity = "low"` when the payload does not already specify verbosity +- `service_tier = "priority"` for direct `openai/*` Responses calls to `api.openai.com` + +Example: + +```json5 +{ + agents: { + defaults: { + models: { + "openai/gpt-5.4": { + params: { + fastMode: true, + }, + }, + "openai-codex/gpt-5.4": { + params: { + fastMode: true, + }, + }, + }, + }, + }, +} +``` + +Session overrides win over config. Clearing the session override in the Sessions UI +returns the session to the configured default. + ### OpenAI Responses server-side compaction For direct OpenAI Responses models (`openai/*` using `api: "openai-responses"` with diff --git a/docs/providers/sglang.md b/docs/providers/sglang.md new file mode 100644 index 00000000000..ce66950c0c3 --- /dev/null +++ b/docs/providers/sglang.md @@ -0,0 +1,104 @@ +--- +summary: "Run OpenClaw with SGLang (OpenAI-compatible self-hosted server)" +read_when: + - You want to run OpenClaw against a local SGLang server + - You want OpenAI-compatible /v1 endpoints with your own models +title: "SGLang" +--- + +# SGLang + +SGLang can serve open-source models via an **OpenAI-compatible** HTTP API. +OpenClaw can connect to SGLang using the `openai-completions` API. + +OpenClaw can also **auto-discover** available models from SGLang when you opt +in with `SGLANG_API_KEY` (any value works if your server does not enforce auth) +and you do not define an explicit `models.providers.sglang` entry. + +## Quick start + +1. Start SGLang with an OpenAI-compatible server. + +Your base URL should expose `/v1` endpoints (for example `/v1/models`, +`/v1/chat/completions`). SGLang commonly runs on: + +- `http://127.0.0.1:30000/v1` + +2. Opt in (any value works if no auth is configured): + +```bash +export SGLANG_API_KEY="sglang-local" +``` + +3. Run onboarding and choose `SGLang`, or set a model directly: + +```bash +openclaw onboard +``` + +```json5 +{ + agents: { + defaults: { + model: { primary: "sglang/your-model-id" }, + }, + }, +} +``` + +## Model discovery (implicit provider) + +When `SGLANG_API_KEY` is set (or an auth profile exists) and you **do not** +define `models.providers.sglang`, OpenClaw will query: + +- `GET http://127.0.0.1:30000/v1/models` + +and convert the returned IDs into model entries. + +If you set `models.providers.sglang` explicitly, auto-discovery is skipped and +you must define models manually. + +## Explicit configuration (manual models) + +Use explicit config when: + +- SGLang runs on a different host/port. +- You want to pin `contextWindow`/`maxTokens` values. +- Your server requires a real API key (or you want to control headers). + +```json5 +{ + models: { + providers: { + sglang: { + baseUrl: "http://127.0.0.1:30000/v1", + apiKey: "${SGLANG_API_KEY}", + api: "openai-completions", + models: [ + { + id: "your-model-id", + name: "Local SGLang Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }, + }, + }, +} +``` + +## Troubleshooting + +- Check the server is reachable: + +```bash +curl http://127.0.0.1:30000/v1/models +``` + +- If requests fail with auth errors, set a real `SGLANG_API_KEY` that matches + your server configuration, or configure the provider explicitly under + `models.providers.sglang`. diff --git a/docs/providers/zai.md b/docs/providers/zai.md index 93313acba3f..6f3aea27020 100644 --- a/docs/providers/zai.md +++ b/docs/providers/zai.md @@ -15,9 +15,17 @@ with a Z.AI API key. ## CLI setup ```bash -openclaw onboard --auth-choice zai-api-key -# or non-interactive -openclaw onboard --zai-api-key "$ZAI_API_KEY" +# Coding Plan Global, recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-global + +# Coding Plan CN (China region), recommended for Coding Plan users +openclaw onboard --auth-choice zai-coding-cn + +# General API +openclaw onboard --auth-choice zai-global + +# General API CN (China region) +openclaw onboard --auth-choice zai-cn ``` ## Config snippet diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index b13803e69f3..ed11040d325 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -9,7 +9,7 @@ read_when: # Release Checklist (npm + macOS) -Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing. +Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing. ## Operator trigger @@ -38,7 +38,7 @@ Current OpenClaw releases use date-based versioning. - `latest` = stable - `beta` = prerelease/testing - Dev is the moving head of `main`, not a normal git-tagged release. -- The release workflow enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. +- The tag-triggered preview run enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date. Historical note: @@ -94,10 +94,12 @@ Historical note: - [ ] Confirm git status is clean; commit and push as needed. - [ ] Confirm npm trusted publishing is configured for the `openclaw` package. -- [ ] Push the matching git tag to trigger `.github/workflows/openclaw-npm-release.yml`. +- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing. +- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`. +- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval. - Stable tags publish to npm `latest`. - Beta tags publish to npm `beta`. - - The workflow rejects tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. + - Both the preview run and the manual publish run reject tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date. - [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`). ### Troubleshooting (notes from 2.0.0-beta2 release) diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index baf4302ac0d..bbb1d90de87 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -85,8 +85,8 @@ See [Memory](/concepts/memory). - **Kimi (Moonshot)**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey` - **Perplexity Search API**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` -**Brave Search free credit:** Each Brave plan includes $5/month in renewing -free credit. The Search plan costs $5 per 1,000 requests, so the credit covers +**Brave Search free credit:** Each Brave plan includes \$5/month in renewing +free credit. The Search plan costs \$5 per 1,000 requests, so the credit covers 1,000 requests/month at no charge. Set your usage limit in the Brave dashboard to avoid unexpected charges. diff --git a/docs/reference/secretref-credential-surface.md b/docs/reference/secretref-credential-surface.md index 76eb4ec2ae1..9f73c7d0112 100644 --- a/docs/reference/secretref-credential-surface.md +++ b/docs/reference/secretref-credential-surface.md @@ -69,8 +69,10 @@ Scope intent: - `channels.bluebubbles.password` - `channels.bluebubbles.accounts.*.password` - `channels.feishu.appSecret` +- `channels.feishu.encryptKey` - `channels.feishu.verificationToken` - `channels.feishu.accounts.*.appSecret` +- `channels.feishu.accounts.*.encryptKey` - `channels.feishu.accounts.*.verificationToken` - `channels.msteams.appPassword` - `channels.mattermost.botToken` diff --git a/docs/reference/secretref-user-supplied-credentials-matrix.json b/docs/reference/secretref-user-supplied-credentials-matrix.json index 6d4b05d2822..f72729dbadc 100644 --- a/docs/reference/secretref-user-supplied-credentials-matrix.json +++ b/docs/reference/secretref-user-supplied-credentials-matrix.json @@ -128,6 +128,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "channels.feishu.accounts.*.encryptKey", + "configFile": "openclaw.json", + "path": "channels.feishu.accounts.*.encryptKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "channels.feishu.accounts.*.verificationToken", "configFile": "openclaw.json", @@ -142,6 +149,13 @@ "secretShape": "secret_input", "optIn": true }, + { + "id": "channels.feishu.encryptKey", + "configFile": "openclaw.json", + "path": "channels.feishu.encryptKey", + "secretShape": "secret_input", + "optIn": true + }, { "id": "channels.feishu.verificationToken", "configFile": "openclaw.json", diff --git a/docs/reference/test.md b/docs/reference/test.md index 8d99e674c3f..378789f6d6e 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -11,7 +11,7 @@ title: "Tests" - `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied. - `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic. -- `pnpm test` on Node 24+: OpenClaw auto-disables Vitest `vmForks` and uses `forks` to avoid `ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`. +- `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`. - `pnpm test`: runs the fast core unit lane by default for quick local feedback. - `pnpm test:channels`: runs channel-heavy suites. - `pnpm test:extensions`: runs extension/plugin suites. @@ -81,7 +81,7 @@ This script drives the interactive wizard via a pseudo-tty, verifies config/work ## QR import smoke (Docker) -Ensures `qrcode-terminal` loads under Node 22+ in Docker: +Ensures `qrcode-terminal` loads under the supported Docker Node runtimes (Node 24 default, Node 22 compatible): ```bash pnpm test:docker:qr 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/reference/wizard.md b/docs/reference/wizard.md index d58ab96c83a..bbaebbdc84f 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -39,6 +39,8 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard). - **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then stores it in auth profiles. - **xAI (Grok) API key**: prompts for `XAI_API_KEY` and configures xAI as a model provider. - **OpenCode**: prompts for `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`, get it at https://opencode.ai/auth) and lets you pick the Zen or Go catalog. + - **Ollama**: prompts for the Ollama base URL, offers **Cloud + Local** or **Local** mode, discovers available models, and auto-pulls the selected local model when needed. + - More detail: [Ollama](/providers/ollama) - **API key**: stores the key for you. - **Vercel AI Gateway (multi-model proxy)**: prompts for `AI_GATEWAY_API_KEY`. - More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway) @@ -165,81 +167,8 @@ openclaw onboard --non-interactive \ `--json` does **not** imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts. - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice gemini-api-key \ - --gemini-api-key "$GEMINI_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice zai-api-key \ - --zai-api-key "$ZAI_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice ai-gateway-api-key \ - --ai-gateway-api-key "$AI_GATEWAY_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice cloudflare-ai-gateway-api-key \ - --cloudflare-ai-gateway-account-id "your-account-id" \ - --cloudflare-ai-gateway-gateway-id "your-gateway-id" \ - --cloudflare-ai-gateway-api-key "$CLOUDFLARE_AI_GATEWAY_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice moonshot-api-key \ - --moonshot-api-key "$MOONSHOT_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice synthetic-api-key \ - --synthetic-api-key "$SYNTHETIC_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - - - ```bash - openclaw onboard --non-interactive \ - --mode local \ - --auth-choice opencode-zen \ - --opencode-zen-api-key "$OPENCODE_API_KEY" \ - --gateway-port 18789 \ - --gateway-bind loopback - ``` - Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog. - - +Provider-specific command examples live in [CLI Automation](/start/wizard-cli-automation#provider-specific-examples). +Use this reference page for flag semantics and step ordering. ### Add agent (non-interactive) diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index c4bed93d33f..26b54b63f6f 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -19,7 +19,7 @@ Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui). ## Prereqs -- Node 22 or newer +- Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported for compatibility) Check your Node version with `node --version` if you are unsure. diff --git a/docs/start/wizard-cli-automation.md b/docs/start/wizard-cli-automation.md index 8547f60ac19..cd00787c5c7 100644 --- a/docs/start/wizard-cli-automation.md +++ b/docs/start/wizard-cli-automation.md @@ -134,6 +134,17 @@ openclaw onboard --non-interactive \ ``` Swap to `--auth-choice opencode-go --opencode-go-api-key "$OPENCODE_API_KEY"` for the Go catalog. + + ```bash + openclaw onboard --non-interactive \ + --mode local \ + --auth-choice ollama \ + --custom-model-id "qwen3.5:27b" \ + --accept-risk \ + --gateway-port 18789 \ + --gateway-bind loopback + ``` + ```bash openclaw onboard --non-interactive \ diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 20f99accd8d..5d3e6be6e72 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -16,7 +16,7 @@ For the short guide, see [Onboarding Wizard (CLI)](/start/wizard). Local mode (default) walks you through: -- Model and auth setup (OpenAI Code subscription OAuth, Anthropic API key or setup token, plus MiniMax, GLM, Moonshot, and AI Gateway options) +- Model and auth setup (OpenAI Code subscription OAuth, Anthropic API key or setup token, plus MiniMax, GLM, Ollama, Moonshot, and AI Gateway options) - Workspace location and bootstrap files - Gateway settings (port, bind, auth, tailscale) - Channels and providers (Telegram, WhatsApp, Discord, Google Chat, Mattermost plugin, Signal) @@ -178,6 +178,11 @@ What you set: Prompts for `SYNTHETIC_API_KEY`. More detail: [Synthetic](/providers/synthetic). + + Prompts for base URL (default `http://127.0.0.1:11434`), then offers Cloud + Local or Local mode. + Discovers available models and suggests defaults. + More detail: [Ollama](/providers/ollama). + Moonshot (Kimi K2) and Kimi Coding configs are auto-written. More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot). diff --git a/docs/start/wizard.md b/docs/start/wizard.md index ef1fc52b31a..05c09ed53fd 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -111,8 +111,10 @@ Notes: ## Full reference -For detailed step-by-step breakdowns, non-interactive scripting, Signal setup, -RPC API, and a full list of config fields the wizard writes, see the +For detailed step-by-step breakdowns and config outputs, see +[CLI Onboarding Reference](/start/wizard-cli-reference). +For non-interactive examples, see [CLI Automation](/start/wizard-cli-automation). +For the deeper technical reference, including RPC details, see [Wizard Reference](/reference/wizard). ## Related docs diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 65a320f1c52..d8ac5b5f7d3 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -421,6 +421,8 @@ Some controls depend on backend capabilities. If a backend does not support a co | `/acp doctor` | Backend health, capabilities, actionable fixes. | `/acp doctor` | | `/acp install` | Print deterministic install and enable steps. | `/acp install` | +`/acp sessions` reads the store for the current bound or requester session. Commands that accept `session-key`, `session-id`, or `session-label` tokens resolve targets through gateway session discovery, including custom per-agent `session.store` roots. + ## Runtime options mapping `/acp` has convenience commands and a generic setter. diff --git a/docs/tools/browser-linux-troubleshooting.md b/docs/tools/browser-linux-troubleshooting.md index 01e6cbc3ff9..1ab51657044 100644 --- a/docs/tools/browser-linux-troubleshooting.md +++ b/docs/tools/browser-linux-troubleshooting.md @@ -123,7 +123,7 @@ curl -s http://127.0.0.1:18791/tabs ### Problem: "Chrome extension relay is running, but no tab is connected" -You’re using the `chrome` profile (extension relay). It expects the OpenClaw +You’re using the `chrome-relay` profile (extension relay). It expects the OpenClaw browser extension to be attached to a live tab. Fix options: @@ -135,5 +135,5 @@ Fix options: Notes: -- The `chrome` profile uses your **system default Chromium browser** when possible. +- The `chrome-relay` profile uses your **system default Chromium browser** when possible. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl`; only set those for remote CDP. diff --git a/docs/tools/browser-login.md b/docs/tools/browser-login.md index 910c21ca218..d570b3b2e87 100644 --- a/docs/tools/browser-login.md +++ b/docs/tools/browser-login.md @@ -20,6 +20,13 @@ Back to the main browser docs: [Browser](/tools/browser). OpenClaw controls a **dedicated Chrome profile** (named `openclaw`, orange‑tinted UI). This is separate from your daily browser profile. +For agent browser tool calls: + +- Default choice: the agent should use its isolated `openclaw` browser. +- Use `profile="user"` only when existing logged-in sessions matter and the user is at the computer to click/approve any attach prompt. +- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. +- If you have multiple user-browser profiles, specify the profile explicitly instead of guessing. + Two easy ways to access it: 1. **Ask the agent to open the browser** and then log in yourself. diff --git a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md index d63bb891c48..2e7844860aa 100644 --- a/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md +++ b/docs/tools/browser-wsl2-windows-remote-cdp-troubleshooting.md @@ -33,7 +33,7 @@ Choose this when: ### Option 2: Chrome extension relay -Use the built-in `chrome` profile plus the OpenClaw Chrome extension. +Use the built-in `chrome-relay` profile plus the OpenClaw Chrome extension. Choose this when: @@ -155,7 +155,7 @@ Example: { browser: { enabled: true, - defaultProfile: "chrome", + defaultProfile: "chrome-relay", relayBindHost: "0.0.0.0", }, } @@ -197,7 +197,7 @@ openclaw browser tabs --browser-profile remote For the extension relay: ```bash -openclaw browser tabs --browser-profile chrome +openclaw browser tabs --browser-profile chrome-relay ``` Good result: diff --git a/docs/tools/browser.md b/docs/tools/browser.md index d632e713068..ebe352036c5 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -18,8 +18,8 @@ Beginner view: - Think of it as a **separate, agent-only browser**. - The `openclaw` profile does **not** touch your personal browser profile. - The agent can **open tabs, read pages, click, and type** in a safe lane. -- The default `chrome` profile uses the **system default Chromium browser** via the - extension relay; switch to `openclaw` for the isolated managed browser. +- The built-in `user` profile attaches to your real signed-in Chrome session; + `chrome-relay` is the explicit extension-relay profile. ## What you get @@ -43,11 +43,22 @@ openclaw browser --browser-profile openclaw snapshot If you get “Browser disabled”, enable it in config (see below) and restart the Gateway. -## Profiles: `openclaw` vs `chrome` +## Profiles: `openclaw` vs `user` vs `chrome-relay` - `openclaw`: managed, isolated browser (no extension required). -- `chrome`: extension relay to your **system browser** (requires the OpenClaw - extension to be attached to a tab). +- `user`: built-in Chrome MCP attach profile for your **real signed-in Chrome** + session. +- `chrome-relay`: extension relay to your **system browser** (requires the + OpenClaw extension to be attached to a tab). + +For agent browser tool calls: + +- Default: use the isolated `openclaw` browser. +- Prefer `profile="user"` when existing logged-in sessions matter and the user + is at the computer to click/approve any attach prompt. +- Use `profile="chrome-relay"` only when the user explicitly wants the Chrome + extension / toolbar-button attach flow. +- `profile` is the explicit override when you want a specific browser mode. Set `browser.defaultProfile: "openclaw"` if you want managed mode by default. @@ -68,7 +79,7 @@ Browser settings live in `~/.openclaw/openclaw.json`. // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms) remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms) - defaultProfile: "chrome", + defaultProfile: "openclaw", color: "#FF4500", headless: false, noSandbox: false, @@ -77,6 +88,16 @@ Browser settings live in `~/.openclaw/openclaw.json`. profiles: { openclaw: { cdpPort: 18800, color: "#FF4500" }, work: { cdpPort: 18801, color: "#0066CC" }, + user: { + driver: "existing-session", + attachOnly: true, + color: "#00AA00", + }, + "chrome-relay": { + driver: "extension", + cdpUrl: "http://127.0.0.1:18792", + color: "#00AA00", + }, remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }, }, }, @@ -97,9 +118,11 @@ Notes: - `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility. - `attachOnly: true` means “never launch a local browser; only attach if it is already running.” - `color` + per-profile `color` tint the browser UI so you can see which profile is active. -- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay. +- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "user"` to opt into the signed-in user browser, or `defaultProfile: "chrome-relay"` for the extension relay. - Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary. - Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP. +- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do + not set `cdpUrl` for that driver. ## Use Brave (or another Chromium-based browser) @@ -264,11 +287,13 @@ OpenClaw supports multiple named profiles (routing configs). Profiles can be: - **openclaw-managed**: a dedicated Chromium-based browser instance with its own user data directory + CDP port - **remote**: an explicit CDP URL (Chromium-based browser running elsewhere) - **extension relay**: your existing Chrome tab(s) via the local relay + Chrome extension +- **existing session**: your existing Chrome profile via Chrome DevTools MCP auto-connect Defaults: - The `openclaw` profile is auto-created if missing. -- The `chrome` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). +- The `chrome-relay` profile is built-in for the Chrome extension relay (points at `http://127.0.0.1:18792` by default). +- Existing-session profiles are opt-in; create them with `--driver existing-session`. - Local CDP ports allocate from **18800–18899** by default. - Deleting a profile moves its local data directory to Trash. @@ -311,8 +336,8 @@ openclaw browser extension install 2. Use it: -- CLI: `openclaw browser --browser-profile chrome tabs` -- Agent tool: `browser` with `profile="chrome"` +- CLI: `openclaw browser --browser-profile chrome-relay tabs` +- Agent tool: `browser` with `profile="chrome-relay"` Optional: if you want a different name or relay port, create your own profile: @@ -328,6 +353,81 @@ Notes: - This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions). - Detach by clicking the extension icon again. +- Agent use: prefer `profile="user"` for logged-in sites. Use `profile="chrome-relay"` + only when you specifically want the extension flow. The user must be present + to click the extension and attach the tab. + +## Chrome existing-session via MCP + +OpenClaw can also attach to a running Chrome profile through the official +Chrome DevTools MCP server. This reuses the tabs and login state already open in +that Chrome profile. + +Official background and setup references: + +- [Chrome for Developers: Use Chrome DevTools MCP with your browser session](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) +- [Chrome DevTools MCP README](https://github.com/ChromeDevTools/chrome-devtools-mcp) + +Built-in profile: + +- `user` + +Optional: create your own custom existing-session profile if you want a +different name or color. + +Then in Chrome: + +1. Open `chrome://inspect/#remote-debugging` +2. Enable remote debugging +3. Keep Chrome running and approve the connection prompt when OpenClaw attaches + +Live attach smoke test: + +```bash +openclaw browser --browser-profile user start +openclaw browser --browser-profile user status +openclaw browser --browser-profile user tabs +openclaw browser --browser-profile user snapshot --format ai +``` + +What success looks like: + +- `status` shows `driver: existing-session` +- `status` shows `transport: chrome-mcp` +- `status` shows `running: true` +- `tabs` lists your already-open Chrome tabs +- `snapshot` returns refs from the selected live tab + +What to check if attach does not work: + +- Chrome is version `144+` +- remote debugging is enabled at `chrome://inspect/#remote-debugging` +- Chrome showed and you accepted the attach consent prompt + +Agent use: + +- Use `profile="user"` when you need the user’s logged-in browser state. +- If you use a custom existing-session profile, pass that explicit profile name. +- Prefer `profile="user"` over `profile="chrome-relay"` unless the user + explicitly wants the extension / attach-tab flow. +- Only choose this mode when the user is at the computer to approve the attach + prompt. +- the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` + +Notes: + +- This path is higher-risk than the isolated `openclaw` profile because it can + act inside your signed-in browser session. +- OpenClaw does not launch Chrome for this driver; it attaches to an existing + session only. +- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not + the legacy default-profile remote debugging port workflow. +- Existing-session screenshots support page captures and `--ref` element + captures from snapshots, but not CSS `--element` selectors. +- Existing-session `wait --url` supports exact, substring, and glob patterns + like other browser drivers. `wait --load networkidle` is not supported yet. +- Some features still require the extension relay or managed browser path, such + as PDF export and download interception. - Leave the relay loopback-only by default. If the relay must be reachable from a different network namespace (for example Gateway in WSL2, Chrome on Windows), set `browser.relayBindHost` to an explicit bind address such as `0.0.0.0` while keeping the surrounding network private and authenticated. WSL2 / cross-namespace example: @@ -337,7 +437,7 @@ WSL2 / cross-namespace example: browser: { enabled: true, relayBindHost: "0.0.0.0", - defaultProfile: "chrome", + defaultProfile: "chrome-relay", }, } ``` diff --git a/docs/tools/chrome-extension.md b/docs/tools/chrome-extension.md index ce4b271ae9c..91a6c1240f1 100644 --- a/docs/tools/chrome-extension.md +++ b/docs/tools/chrome-extension.md @@ -13,6 +13,13 @@ The OpenClaw Chrome extension lets the agent control your **existing Chrome tabs Attach/detach happens via a **single Chrome toolbar button**. +If you want Chrome’s official DevTools MCP attach flow instead of the OpenClaw +extension relay, use an `existing-session` browser profile instead. See +[Browser](/tools/browser#chrome-existing-session-via-mcp). For Chrome’s own +setup docs, see [Chrome for Developers: Use Chrome DevTools MCP with your +browser session](https://developer.chrome.com/blog/chrome-devtools-mcp-debug-your-browser-session) +and the [Chrome DevTools MCP README](https://github.com/ChromeDevTools/chrome-devtools-mcp). + ## What it is (concept) There are three parts: @@ -55,7 +62,7 @@ After upgrading OpenClaw: ## Use it (set gateway token once) -OpenClaw ships with a built-in browser profile named `chrome` that targets the extension relay on the default port. +OpenClaw ships with a built-in browser profile named `chrome-relay` that targets the extension relay on the default port. Before first attach, open extension Options and set: @@ -64,8 +71,8 @@ Before first attach, open extension Options and set: Use it: -- CLI: `openclaw browser --browser-profile chrome tabs` -- Agent tool: `browser` with `profile="chrome"` +- CLI: `openclaw browser --browser-profile chrome-relay tabs` +- Agent tool: `browser` with `profile="chrome-relay"` If you want a different name or a different relay port, create your own profile: 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/index.md b/docs/tools/index.md index 6552d6f9118..bdd9b78456f 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -316,7 +316,11 @@ Common parameters: Notes: - Requires `browser.enabled=true` (default is `true`; set `false` to disable). - All actions accept optional `profile` parameter for multi-instance support. -- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome"). +- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`). +- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt. +- Use `profile="chrome-relay"` only for the Chrome extension / toolbar-button attach flow. +- `profile="user"` and `profile="chrome-relay"` are host-only; do not combine them with sandbox/node targets. +- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`). - Profile names: lowercase alphanumeric + hyphens only (max 64 chars). - Port range: 18800-18899 (~100 profiles max). - Remote profiles are attach-only (no start/stop/reset). diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index a257d8b7a45..5455bb2b38d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -43,6 +43,55 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version. See [Voice Call](/plugins/voice-call) for a concrete example plugin. Looking for third-party listings? See [Community plugins](/plugins/community). +## Architecture + +OpenClaw's plugin system has four layers: + +1. **Manifest + discovery** + OpenClaw finds candidate plugins from configured paths, workspace roots, + global extension roots, and bundled extensions. Discovery reads + `openclaw.plugin.json` plus package metadata first. +2. **Enablement + validation** + Core decides whether a discovered plugin is enabled, disabled, blocked, or + selected for an exclusive slot such as memory. +3. **Runtime loading** + Enabled plugins are loaded in-process via jiti and register capabilities into + a central registry. +4. **Surface consumption** + The rest of OpenClaw reads the registry to expose tools, channels, provider + setup, hooks, HTTP routes, CLI commands, and services. + +The important design boundary: + +- discovery + config validation should work from **manifest/schema metadata** + without executing plugin code +- runtime behavior comes from the plugin module's `register(api)` path + +That split lets OpenClaw validate config, explain missing/disabled plugins, and +build UI/schema hints before the full runtime is active. + +## Execution model + +Plugins run **in-process** with the Gateway. They are not sandboxed. A loaded +plugin has the same process-level trust boundary as core code. + +Implications: + +- a plugin can register tools, network handlers, hooks, and services +- a plugin bug can crash or destabilize the gateway +- a malicious plugin is equivalent to arbitrary code execution inside the + OpenClaw process + +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. @@ -78,6 +127,48 @@ Plugins can register: Plugins run **in‑process** with the Gateway, so treat them as trusted code. Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). +## Load pipeline + +At startup, OpenClaw does roughly this: + +1. discover candidate plugin roots +2. read `openclaw.plugin.json` and package metadata +3. reject unsafe candidates +4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`, + `slots`, `load.paths`) +5. decide enablement for each candidate +6. load enabled modules via jiti +7. call `register(api)` and collect registrations into the plugin registry +8. expose the registry to commands/runtime surfaces + +The safety gates happen **before** runtime execution. Candidates are blocked +when the entry escapes the plugin root, the path is world-writable, or path +ownership looks suspicious for non-bundled plugins. + +### Manifest-first behavior + +The manifest is the control-plane source of truth. OpenClaw uses it to: + +- identify the plugin +- discover declared channels/skills/config schema +- validate `plugins.entries..config` +- augment Control UI labels/placeholders +- show install/catalog metadata + +The runtime module is the data-plane part. It registers actual behavior such as +hooks, tools, commands, or provider flows. + +### What the loader caches + +OpenClaw keeps short in-process caches for: + +- discovery results +- manifest registry data +- loaded plugin registries + +These caches reduce bursty startup and repeated command overhead. They are safe +to think of as short-lived performance caches, not persistence. + ## Runtime helpers Plugins can access selected core helpers via `api.runtime`. For telephony TTS: @@ -259,6 +350,10 @@ Default-on bundled plugin exceptions: Installed plugins are enabled by default, but can be disabled the same way. +Workspace plugins are **disabled by default** unless you explicitly enable them +or allowlist them. This is intentional: a checked-out repo should not silently +become production gateway code. + Hardening notes: - If `plugins.allow` is empty and non-bundled plugins are discoverable, OpenClaw logs a startup warning with plugin ids and sources. @@ -275,6 +370,34 @@ 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: + +- `plugins.enabled: false` disables all plugins +- `plugins.deny` always wins +- `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 + - channel config implicitly enables the bundled channel plugin +- exclusive slots can force-enable the selected plugin for that slot + +In current core, bundled default-on ids include local/provider helpers such as +`ollama`, `sglang`, `vllm`, plus `device-pair`, `phone-control`, and +`talk-voice`. + ### Package packs A plugin directory may include a `package.json` with `openclaw.extensions`: @@ -354,6 +477,34 @@ Default plugin ids: If a plugin exports `id`, OpenClaw uses it but warns when it doesn’t match the configured id. +## Registry model + +Loaded plugins do not directly mutate random core globals. They register into a +central plugin registry. + +The registry tracks: + +- plugin records (identity, source, origin, status, diagnostics) +- tools +- legacy hooks and typed hooks +- channels +- providers +- gateway RPC handlers +- HTTP routes +- CLI registrars +- background services +- plugin-owned commands + +Core features then read from that registry instead of talking to plugin modules +directly. This keeps loading one-way: + +- plugin module -> registry registration +- core runtime -> registry consumption + +That separation matters for maintainability. It means most core surfaces only +need one integration point: "read the registry", not "special-case every plugin +module". + ## Config ```json5 @@ -390,6 +541,17 @@ Validation rules (strict): `openclaw.plugin.json` (`configSchema`). - If a plugin is disabled, its config is preserved and a **warning** is emitted. +### Disabled vs missing vs invalid + +These states are intentionally different: + +- **disabled**: plugin exists, but enablement rules turned it off +- **missing**: config references a plugin id that discovery did not find +- **invalid**: plugin exists, but its config does not match the declared schema + +OpenClaw preserves config for disabled plugins so toggling them back on is not +destructive. + ## Plugin slots (exclusive categories) Some plugin categories are **exclusive** (only one active at a time). Use @@ -488,6 +650,19 @@ Plugins export either: - A function: `(api) => { ... }` - An object: `{ id, name, configSchema, register(api) { ... } }` +`register(api)` is where plugins attach behavior. Common registrations include: + +- `registerTool` +- `registerHook` +- `on(...)` for typed lifecycle hooks +- `registerChannel` +- `registerProvider` +- `registerHttpRoute` +- `registerCommand` +- `registerCli` +- `registerContextEngine` +- `registerService` + Context engine plugins can also register a runtime-owned context manager: ```ts @@ -603,13 +778,188 @@ Migration guidance: ## Provider plugins (model auth) -Plugins can register **model provider auth** flows so users can run OAuth or -API-key setup inside OpenClaw (no external scripts needed). +Plugins can register **model providers** so users can run OAuth or API-key +setup inside OpenClaw, surface provider setup in onboarding/model-pickers, and +contribute implicit provider discovery. + +Provider plugins are the modular extension seam for model-provider setup. They +are not just "OAuth helpers" anymore. + +### Provider plugin lifecycle + +A provider plugin can participate in five distinct phases: + +1. **Auth** + `auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom + setup and returns auth profiles plus optional config patches. +2. **Non-interactive setup** + `auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive` + without prompts. Use this when the provider needs custom headless setup + beyond the built-in simple API-key paths. +3. **Wizard integration** + `wizard.onboarding` adds an entry to `openclaw onboard`. + `wizard.modelPicker` adds a setup entry to the model picker. +4. **Implicit discovery** + `discovery.run(ctx)` can contribute provider config automatically during + model resolution/listing. +5. **Post-selection follow-up** + `onModelSelected(ctx)` runs after a model is chosen. Use this for provider- + specific work such as downloading a local model. + +This is the recommended split because these phases have different lifecycle +requirements: + +- auth is interactive and writes credentials/config +- non-interactive setup is flag/env-driven and must not prompt +- wizard metadata is static and UI-facing +- discovery should be safe, quick, and failure-tolerant +- post-select hooks are side effects tied to the chosen model + +### Provider auth contract + +`auth[].run(ctx)` returns: + +- `profiles`: auth profiles to write +- `configPatch`: optional `openclaw.json` changes +- `defaultModel`: optional `provider/model` ref +- `notes`: optional user-facing notes + +Core then: + +1. writes the returned auth profiles +2. applies auth-profile config wiring +3. merges the config patch +4. optionally applies the default model +5. runs the provider's `onModelSelected` hook when appropriate + +That means a provider plugin owns the provider-specific setup logic, while core +owns the generic persistence and config-merge path. + +### Provider non-interactive contract + +`auth[].runNonInteractive(ctx)` is optional. Implement it when the provider +needs headless setup that cannot be expressed through the built-in generic +API-key flows. + +The non-interactive context includes: + +- the current and base config +- parsed onboarding CLI options +- runtime logging/error helpers +- agent/workspace dirs +- `resolveApiKey(...)` to read provider keys from flags, env, or existing auth + profiles while honoring `--secret-input-mode` +- `toApiKeyCredential(...)` to convert a resolved key into an auth-profile + credential with the right plaintext vs secret-ref storage + +Use this surface for providers such as: + +- self-hosted OpenAI-compatible runtimes that need `--custom-base-url` + + `--custom-model-id` +- provider-specific non-interactive verification or config synthesis + +Do not prompt from `runNonInteractive`. Reject missing inputs with actionable +errors instead. + +### Provider wizard metadata + +`wizard.onboarding` controls how the provider appears in grouped onboarding: + +- `choiceId`: auth-choice value +- `choiceLabel`: option label +- `choiceHint`: short hint +- `groupId`: group bucket id +- `groupLabel`: group label +- `groupHint`: group hint +- `methodId`: auth method to run + +`wizard.modelPicker` controls how a provider appears as a "set this up now" +entry in model selection: + +- `label` +- `hint` +- `methodId` + +When a provider has multiple auth methods, the wizard can either point at one +explicit method or let OpenClaw synthesize per-method choices. + +OpenClaw validates provider wizard metadata when the plugin registers: + +- duplicate or blank auth-method ids are rejected +- wizard metadata is ignored when the provider has no auth methods +- invalid `methodId` bindings are downgraded to warnings and fall back to the + provider's remaining auth methods + +### Provider discovery contract + +`discovery.run(ctx)` returns one of: + +- `{ provider }` +- `{ providers }` +- `null` + +Use `{ provider }` for the common case where the plugin owns one provider id. +Use `{ providers }` when a plugin discovers multiple provider entries. + +The discovery context includes: + +- the current config +- agent/workspace dirs +- process env +- a helper to resolve the provider API key and a discovery-safe API key value + +Discovery should be: + +- fast +- best-effort +- safe to skip on failure +- careful about side effects + +It should not depend on prompts or long-running setup. + +### Discovery ordering + +Provider discovery runs in ordered phases: + +- `simple` +- `profile` +- `paired` +- `late` + +Use: + +- `simple` for cheap environment-only discovery +- `profile` when discovery depends on auth profiles +- `paired` for providers that need to coordinate with another discovery step +- `late` for expensive or local-network probing + +Most self-hosted providers should use `late`. + +### Good provider-plugin boundaries + +Good fit for provider plugins: + +- local/self-hosted providers with custom setup flows +- provider-specific OAuth/device-code login +- implicit discovery of local model servers +- post-selection side effects such as model pulls + +Less compelling fit: + +- trivial API-key-only providers that differ only by env var, base URL, and one + default model + +Those can still become plugins, but the main modularity payoff comes from +extracting behavior-rich providers first. Register a provider via `api.registerProvider(...)`. Each provider exposes one -or more auth methods (OAuth, API key, device code, etc.). These methods power: +or more auth methods (OAuth, API key, device code, etc.). Those methods can +power: - `openclaw models auth login --provider [--method ]` +- `openclaw onboard` +- model-picker “custom provider” setup entries +- implicit provider discovery during model resolution/listing Example: @@ -642,6 +992,31 @@ api.registerProvider({ }, }, ], + wizard: { + onboarding: { + choiceId: "acme", + choiceLabel: "AcmeAI", + groupId: "acme", + groupLabel: "AcmeAI", + methodId: "oauth", + }, + modelPicker: { + label: "AcmeAI (custom)", + hint: "Connect a self-hosted AcmeAI endpoint", + methodId: "oauth", + }, + }, + discovery: { + order: "late", + run: async () => ({ + provider: { + baseUrl: "https://acme.example/v1", + api: "openai-completions", + apiKey: "${ACME_API_KEY}", + models: [], + }, + }), + }, }); ``` @@ -649,8 +1024,19 @@ Notes: - `run` receives a `ProviderAuthContext` with `prompter`, `runtime`, `openUrl`, and `oauth.createVpsAwareHandlers` helpers. +- `runNonInteractive` receives a `ProviderAuthMethodNonInteractiveContext` + with `opts`, `resolveApiKey`, and `toApiKeyCredential` helpers for + headless onboarding. - Return `configPatch` when you need to add default models or provider config. - Return `defaultModel` so `--set-default` can update agent defaults. +- `wizard.onboarding` adds a provider choice to `openclaw onboard`. +- `wizard.modelPicker` adds a “setup this provider” entry to the model picker. +- `discovery.run` returns either `{ provider }` for the plugin’s own provider id + or `{ providers }` for multi-provider discovery. +- `discovery.order` controls when the provider runs relative to built-in + discovery phases: `simple`, `profile`, `paired`, or `late`. +- `onModelSelected` is the post-selection hook for provider-specific follow-up + work such as pulling a local model. ### Register a messaging channel @@ -952,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/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index d792398f1fa..0fe5f383f24 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -14,7 +14,7 @@ The host-only bash chat command uses `! ` (with `/bash ` as an alias). There are two related systems: - **Commands**: standalone `/...` messages. -- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. +- **Directives**: `/think`, `/fast`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`. - Directives are stripped from the message before the model sees it. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. @@ -76,6 +76,7 @@ Text + native (when enabled): - `/allowlist` (list/add/remove allowlist entries) - `/approve allow-once|allow-always|deny` (resolve exec approval prompts) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) +- `/btw ` (ask a quick side question about the current session without changing future session context) - `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt) - `/whoami` (show your sender id; alias: `/id`) - `/session idle ` (manage inactivity auto-unfocus for focused thread bindings) @@ -102,6 +103,7 @@ Text + native (when enabled): - `/send on|off|inherit` (owner-only) - `/reset` or `/new [model]` (optional model hint; remainder is passed through) - `/think ` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) +- `/fast status|on|off` (omitting the arg shows the current effective fast-mode state) - `/verbose on|full|off` (alias: `/v`) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals) @@ -130,6 +132,7 @@ Notes: - Discord thread-binding commands (`/focus`, `/unfocus`, `/agents`, `/session idle`, `/session max-age`) require effective thread bindings to be enabled (`session.threadBindings.enabled` and/or `channels.discord.threadBindings.enabled`). - ACP command reference and runtime behavior: [ACP Agents](/tools/acp-agents). - `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. +- `/fast on|off` persists a session override. Use the Sessions UI `inherit` option to clear it and fall back to config defaults. - Tool failure summaries are still shown when relevant, but detailed failure text is only included when `/verbose` is `on` or `full`. - `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md index 9a2fdc87ea6..045911c92b2 100644 --- a/docs/tools/thinking.md +++ b/docs/tools/thinking.md @@ -1,7 +1,7 @@ --- -summary: "Directive syntax for /think + /verbose and how they affect model reasoning" +summary: "Directive syntax for /think, /fast, /verbose, and reasoning visibility" read_when: - - Adjusting thinking or verbose directive parsing or defaults + - Adjusting thinking, fast-mode, or verbose directive parsing or defaults title: "Thinking Levels" --- @@ -42,6 +42,21 @@ title: "Thinking Levels" - **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime. +## Fast mode (/fast) + +- Levels: `on|off`. +- Directive-only message toggles a session fast-mode override and replies `Fast mode enabled.` / `Fast mode disabled.`. +- Send `/fast` (or `/fast status`) with no mode to see the current effective fast-mode state. +- OpenClaw resolves fast mode in this order: + 1. Inline/directive-only `/fast on|off` + 2. Session override + 3. Per-model config: `agents.defaults.models["/"].params.fastMode` + 4. Fallback: `off` +- For `openai/*`, fast mode applies the OpenAI fast profile: `service_tier=priority` when supported, plus low reasoning effort and low text verbosity. +- For `openai-codex/*`, fast mode applies the same low-latency profile on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths. +- For direct `anthropic/*` API-key requests, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`. +- Anthropic fast mode is API-key only. OpenClaw skips Anthropic service-tier injection for Claude setup-token / OAuth auth and for non-Anthropic proxy base URLs. + ## Verbose directives (/verbose or /v) - Levels: `on` (minimal) | `full` | `off` (default). diff --git a/docs/tools/web.md b/docs/tools/web.md index e77d046ce5b..a2aa1d37bfd 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -65,8 +65,8 @@ Use `openclaw configure --section web` to set up your API key and choose a provi 2. In the dashboard, choose the **Search** plan and generate an API key. 3. Run `openclaw configure --section web` to store the key in config, or set `BRAVE_API_KEY` in your environment. -Each Brave plan includes **$5/month in free credit** (renewing). The Search -plan costs $5 per 1,000 requests, so the credit covers 1,000 queries/month. Set +Each Brave plan includes **\$5/month in free credit** (renewing). The Search +plan costs \$5 per 1,000 requests, so the credit covers 1,000 queries/month. Set your usage limit in the Brave dashboard to avoid unexpected charges. See the [Brave API portal](https://brave.com/search/api/) for current plans and pricing. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 59e9c0c226b..73487cc0eae 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -75,7 +75,7 @@ The Control UI can localize itself on first load based on your browser locale, a - Stream tool calls + live tool output cards in Chat (agent events) - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) -- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) +- Sessions: list + per-session thinking/fast/verbose/reasoning overrides (`sessions.list`, `sessions.patch`) - Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) diff --git a/docs/web/tui.md b/docs/web/tui.md index 0c09cb1f877..d1869821d68 100644 --- a/docs/web/tui.md +++ b/docs/web/tui.md @@ -37,7 +37,7 @@ Use `--password` if your Gateway uses password auth. - Header: connection URL, current agent, current session. - Chat log: user messages, assistant replies, system notices, tool cards. - Status line: connection/run state (connecting, running, streaming, idle, error). -- Footer: connection state + agent + session + model + think/verbose/reasoning + token counts + deliver. +- Footer: connection state + agent + session + model + think/fast/verbose/reasoning + token counts + deliver. - Input: text editor with autocomplete. ## Mental model: agents + sessions @@ -92,6 +92,7 @@ Core: Session controls: - `/think ` +- `/fast ` - `/verbose ` - `/reasoning ` - `/usage ` diff --git a/docs/zh-CN/automation/cron-jobs.md b/docs/zh-CN/automation/cron-jobs.md index 185779a2636..cfdb0c178e1 100644 --- a/docs/zh-CN/automation/cron-jobs.md +++ b/docs/zh-CN/automation/cron-jobs.md @@ -28,7 +28,9 @@ x-i18n: - 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。 - 两种执行方式: - **主会话**:入队一个系统事件,然后在下一次心跳时运行。 - - **隔离式**:在 `cron:` 中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **隔离式**:在 `cron:` 或自定义会话中运行专用智能体轮次,可投递摘要(默认 announce)或不投递。 + - **当前会话**:绑定到创建定时任务时的会话 (`sessionTarget: "current"`)。 + - **自定义会话**:在持久化的命名会话中运行 (`sessionTarget: "session:custom-id"`)。 - 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳时"。 ## 快速开始(可操作) @@ -83,6 +85,14 @@ openclaw cron add \ 2. **选择运行位置** - `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。 - `sessionTarget: "isolated"` → 在 `cron:` 中运行专用智能体轮次。 + - `sessionTarget: "current"` → 绑定到当前会话(创建时解析为 `session:`)。 + - `sessionTarget: "session:custom-id"` → 在持久化的命名会话中运行,跨运行保持上下文。 + + 默认行为(保持不变): + - `systemEvent` 负载默认使用 `main` + - `agentTurn` 负载默认使用 `isolated` + + 要使用当前会话绑定,需显式设置 `sessionTarget: "current"`。 3. **选择负载** - 主会话 → `payload.kind = "systemEvent"` @@ -129,12 +139,13 @@ Cron 表达式使用 `croner`。如果省略时区,将使用 Gateway网关主 #### 隔离任务(专用定时会话) -隔离任务在会话 `cron:` 中运行专用智能体轮次。 +隔离任务在会话 `cron:` 或自定义会话中运行专用智能体轮次。 关键行为: - 提示以 `[cron: <任务名称>]` 为前缀,便于追踪。 -- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话)。 +- 每次运行都会启动一个**全新的会话 ID**(不继承之前的对话),除非使用自定义会话。 +- 自定义会话(`session:xxx`)可跨运行保持上下文,适用于如每日站会等需要基于前次摘要的工作流。 - 如果未指定 `delivery`,隔离任务会默认以“announce”方式投递摘要。 - `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)。 diff --git a/extensions/.npmignore b/extensions/.npmignore new file mode 100644 index 00000000000..7cd53fdbc08 --- /dev/null +++ b/extensions/.npmignore @@ -0,0 +1 @@ +**/node_modules/ diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index ae4f7e695ef..d3947cc7552 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -1,10 +1,10 @@ { "name": "@openclaw/acpx", - "version": "2026.3.11", + "version": "2026.3.14", "description": "OpenClaw ACP runtime backend via acpx", "type": "module", "dependencies": { - "acpx": "0.2.0" + "acpx": "0.3.0" }, "openclaw": { "extensions": [ diff --git a/extensions/acpx/src/ensure.test.ts b/extensions/acpx/src/ensure.test.ts index cae52f29f9b..c0bb5469b29 100644 --- a/extensions/acpx/src/ensure.test.ts +++ b/extensions/acpx/src/ensure.test.ts @@ -54,6 +54,49 @@ describe("acpx ensure", () => { } }); + function mockEnsureInstallFlow() { + spawnAndCollectMock + .mockResolvedValueOnce({ + stdout: "acpx 0.0.9\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: "added 1 package\n", + stderr: "", + code: 0, + error: null, + }) + .mockResolvedValueOnce({ + stdout: `acpx ${ACPX_PINNED_VERSION}\n`, + stderr: "", + code: 0, + error: null, + }); + } + + function expectEnsureInstallCalls(stripProviderAuthEnvVars?: boolean) { + expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({ + command: "/plugin/node_modules/.bin/acpx", + args: ["--version"], + cwd: "/plugin", + stripProviderAuthEnvVars, + }); + expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ + command: "npm", + args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], + cwd: "/plugin", + stripProviderAuthEnvVars, + }); + expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({ + command: "/plugin/node_modules/.bin/acpx", + args: ["--version"], + cwd: "/plugin", + stripProviderAuthEnvVars, + }); + } + it("accepts the pinned acpx version", async () => { spawnAndCollectMock.mockResolvedValueOnce({ stdout: `acpx ${ACPX_PINNED_VERSION}\n`, @@ -177,25 +220,7 @@ describe("acpx ensure", () => { }); it("installs and verifies pinned acpx when precheck fails", async () => { - spawnAndCollectMock - .mockResolvedValueOnce({ - stdout: "acpx 0.0.9\n", - stderr: "", - code: 0, - error: null, - }) - .mockResolvedValueOnce({ - stdout: "added 1 package\n", - stderr: "", - code: 0, - error: null, - }) - .mockResolvedValueOnce({ - stdout: `acpx ${ACPX_PINNED_VERSION}\n`, - stderr: "", - code: 0, - error: null, - }); + mockEnsureInstallFlow(); await ensureAcpx({ command: "/plugin/node_modules/.bin/acpx", @@ -204,33 +229,11 @@ describe("acpx ensure", () => { }); expect(spawnAndCollectMock).toHaveBeenCalledTimes(3); - expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ - command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], - cwd: "/plugin", - }); + expectEnsureInstallCalls(); }); it("threads stripProviderAuthEnvVars through version probes and install", async () => { - spawnAndCollectMock - .mockResolvedValueOnce({ - stdout: "acpx 0.0.9\n", - stderr: "", - code: 0, - error: null, - }) - .mockResolvedValueOnce({ - stdout: "added 1 package\n", - stderr: "", - code: 0, - error: null, - }) - .mockResolvedValueOnce({ - stdout: `acpx ${ACPX_PINNED_VERSION}\n`, - stderr: "", - code: 0, - error: null, - }); + mockEnsureInstallFlow(); await ensureAcpx({ command: "/plugin/node_modules/.bin/acpx", @@ -239,24 +242,7 @@ describe("acpx ensure", () => { stripProviderAuthEnvVars: true, }); - expect(spawnAndCollectMock.mock.calls[0]?.[0]).toMatchObject({ - command: "/plugin/node_modules/.bin/acpx", - args: ["--version"], - cwd: "/plugin", - stripProviderAuthEnvVars: true, - }); - expect(spawnAndCollectMock.mock.calls[1]?.[0]).toMatchObject({ - command: "npm", - args: ["install", "--omit=dev", "--no-save", `acpx@${ACPX_PINNED_VERSION}`], - cwd: "/plugin", - stripProviderAuthEnvVars: true, - }); - expect(spawnAndCollectMock.mock.calls[2]?.[0]).toMatchObject({ - command: "/plugin/node_modules/.bin/acpx", - args: ["--version"], - cwd: "/plugin", - stripProviderAuthEnvVars: true, - }); + expectEnsureInstallCalls(true); }); it("fails with actionable error when npm install fails", async () => { 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-internals/process.test.ts b/extensions/acpx/src/runtime-internals/process.test.ts index ba6ad923d3b..ef0492308ae 100644 --- a/extensions/acpx/src/runtime-internals/process.test.ts +++ b/extensions/acpx/src/runtime-internals/process.test.ts @@ -254,6 +254,44 @@ describe("waitForExit", () => { }); describe("spawnAndCollect", () => { + type SpawnedEnvSnapshot = { + openai?: string; + github?: string; + hf?: string; + openclaw?: string; + shell?: string; + }; + + function stubProviderAuthEnv(env: Record) { + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value); + } + } + + async function collectSpawnedEnvSnapshot(options?: { + stripProviderAuthEnvVars?: boolean; + openAiEnvKey?: string; + githubEnvKey?: string; + hfEnvKey?: string; + }): Promise { + const openAiEnvKey = options?.openAiEnvKey ?? "OPENAI_API_KEY"; + const githubEnvKey = options?.githubEnvKey ?? "GITHUB_TOKEN"; + const hfEnvKey = options?.hfEnvKey ?? "HF_TOKEN"; + const result = await spawnAndCollect({ + command: process.execPath, + args: [ + "-e", + `process.stdout.write(JSON.stringify({openai:process.env.${openAiEnvKey},github:process.env.${githubEnvKey},hf:process.env.${hfEnvKey},openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))`, + ], + cwd: process.cwd(), + stripProviderAuthEnvVars: options?.stripProviderAuthEnvVars, + }); + + expect(result.code).toBe(0); + expect(result.error).toBeNull(); + return JSON.parse(result.stdout) as SpawnedEnvSnapshot; + } + it("returns abort error immediately when signal is already aborted", async () => { const controller = new AbortController(); controller.abort(); @@ -292,31 +330,15 @@ describe("spawnAndCollect", () => { }); it("strips shared provider auth env vars from spawned acpx children", async () => { - vi.stubEnv("OPENAI_API_KEY", "openai-secret"); - vi.stubEnv("GITHUB_TOKEN", "gh-secret"); - vi.stubEnv("HF_TOKEN", "hf-secret"); - vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); - - const result = await spawnAndCollect({ - command: process.execPath, - args: [ - "-e", - "process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", - ], - cwd: process.cwd(), + stubProviderAuthEnv({ + OPENAI_API_KEY: "openai-secret", + GITHUB_TOKEN: "gh-secret", + HF_TOKEN: "hf-secret", + OPENCLAW_API_KEY: "keep-me", + }); + const parsed = await collectSpawnedEnvSnapshot({ stripProviderAuthEnvVars: true, }); - - expect(result.code).toBe(0); - expect(result.error).toBeNull(); - - const parsed = JSON.parse(result.stdout) as { - openai?: string; - github?: string; - hf?: string; - openclaw?: string; - shell?: string; - }; expect(parsed.openai).toBeUndefined(); expect(parsed.github).toBeUndefined(); expect(parsed.hf).toBeUndefined(); @@ -325,29 +347,16 @@ describe("spawnAndCollect", () => { }); it("strips provider auth env vars case-insensitively", async () => { - vi.stubEnv("OpenAI_Api_Key", "openai-secret"); - vi.stubEnv("Github_Token", "gh-secret"); - vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); - - const result = await spawnAndCollect({ - command: process.execPath, - args: [ - "-e", - "process.stdout.write(JSON.stringify({openai:process.env.OpenAI_Api_Key,github:process.env.Github_Token,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", - ], - cwd: process.cwd(), - stripProviderAuthEnvVars: true, + stubProviderAuthEnv({ + OpenAI_Api_Key: "openai-secret", + Github_Token: "gh-secret", + OPENCLAW_API_KEY: "keep-me", + }); + const parsed = await collectSpawnedEnvSnapshot({ + stripProviderAuthEnvVars: true, + openAiEnvKey: "OpenAI_Api_Key", + githubEnvKey: "Github_Token", }); - - expect(result.code).toBe(0); - expect(result.error).toBeNull(); - - const parsed = JSON.parse(result.stdout) as { - openai?: string; - github?: string; - openclaw?: string; - shell?: string; - }; expect(parsed.openai).toBeUndefined(); expect(parsed.github).toBeUndefined(); expect(parsed.openclaw).toBe("keep-me"); @@ -355,30 +364,13 @@ describe("spawnAndCollect", () => { }); it("preserves provider auth env vars for explicit custom commands by default", async () => { - vi.stubEnv("OPENAI_API_KEY", "openai-secret"); - vi.stubEnv("GITHUB_TOKEN", "gh-secret"); - vi.stubEnv("HF_TOKEN", "hf-secret"); - vi.stubEnv("OPENCLAW_API_KEY", "keep-me"); - - const result = await spawnAndCollect({ - command: process.execPath, - args: [ - "-e", - "process.stdout.write(JSON.stringify({openai:process.env.OPENAI_API_KEY,github:process.env.GITHUB_TOKEN,hf:process.env.HF_TOKEN,openclaw:process.env.OPENCLAW_API_KEY,shell:process.env.OPENCLAW_SHELL}))", - ], - cwd: process.cwd(), + stubProviderAuthEnv({ + OPENAI_API_KEY: "openai-secret", + GITHUB_TOKEN: "gh-secret", + HF_TOKEN: "hf-secret", + OPENCLAW_API_KEY: "keep-me", }); - - expect(result.code).toBe(0); - expect(result.error).toBeNull(); - - const parsed = JSON.parse(result.stdout) as { - openai?: string; - github?: string; - hf?: string; - openclaw?: string; - shell?: string; - }; + const parsed = await collectSpawnedEnvSnapshot(); expect(parsed.openai).toBe("openai-secret"); expect(parsed.github).toBe("gh-secret"); expect(parsed.hf).toBe("hf-secret"); 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 4918e9d3c02..67df516b8d7 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/bluebubbles", - "version": "2026.3.11", + "version": "2026.3.14", "description": "OpenClaw BlueBubbles channel plugin", "type": "module", "dependencies": { diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts index 8ef94cf08ae..704b907eb8b 100644 --- a/extensions/bluebubbles/src/attachments.test.ts +++ b/extensions/bluebubbles/src/attachments.test.ts @@ -82,6 +82,15 @@ describe("downloadBlueBubblesAttachment", () => { ).rejects.toThrow("too large"); } + function mockSuccessfulAttachmentDownload(buffer = new Uint8Array([1])) { + mockFetch.mockResolvedValueOnce({ + ok: true, + headers: new Headers(), + arrayBuffer: () => Promise.resolve(buffer.buffer), + }); + return buffer; + } + it("throws when guid is missing", async () => { const attachment: BlueBubblesAttachment = {}; await expect( @@ -159,12 +168,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("encodes guid in URL", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att/with/special chars" }; await downloadBlueBubblesAttachment(attachment, { @@ -244,12 +248,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("resolves credentials from config when opts not provided", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-config" }; const result = await downloadBlueBubblesAttachment(attachment, { @@ -270,12 +269,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("passes ssrfPolicy with allowPrivateNetwork when config enables it", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-ssrf" }; await downloadBlueBubblesAttachment(attachment, { @@ -295,12 +289,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("auto-allowlists serverUrl hostname when allowPrivateNetwork is not set", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-no-ssrf" }; await downloadBlueBubblesAttachment(attachment, { @@ -313,12 +302,7 @@ describe("downloadBlueBubblesAttachment", () => { }); it("auto-allowlists private IP serverUrl hostname when allowPrivateNetwork is not set", async () => { - const mockBuffer = new Uint8Array([1]); - mockFetch.mockResolvedValueOnce({ - ok: true, - headers: new Headers(), - arrayBuffer: () => Promise.resolve(mockBuffer.buffer), - }); + mockSuccessfulAttachmentDownload(); const attachment: BlueBubblesAttachment = { guid: "att-private-ip" }; await downloadBlueBubblesAttachment(attachment, { @@ -352,6 +336,14 @@ describe("sendBlueBubblesAttachment", () => { return Buffer.from(body).toString("utf8"); } + function expectVoiceAttachmentBody() { + const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; + const bodyText = decodeBody(body); + expect(bodyText).toContain('name="isAudioMessage"'); + expect(bodyText).toContain("true"); + return bodyText; + } + it("marks voice memos when asVoice is true and mp3 is provided", async () => { mockFetch.mockResolvedValueOnce({ ok: true, @@ -367,10 +359,7 @@ describe("sendBlueBubblesAttachment", () => { opts: { serverUrl: "http://localhost:1234", password: "test" }, }); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); - expect(bodyText).toContain('name="isAudioMessage"'); - expect(bodyText).toContain("true"); + const bodyText = expectVoiceAttachmentBody(); expect(bodyText).toContain('filename="voice.mp3"'); }); @@ -389,8 +378,7 @@ describe("sendBlueBubblesAttachment", () => { opts: { serverUrl: "http://localhost:1234", password: "test" }, }); - const body = mockFetch.mock.calls[0][1]?.body as Uint8Array; - const bodyText = decodeBody(body); + const bodyText = expectVoiceAttachmentBody(); expect(bodyText).toContain('filename="voice.mp3"'); expect(bodyText).toContain('name="voice.mp3"'); }); diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index cbd8a74d807..c5392fd2595 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { postMultipartFormData } from "./multipart.js"; +import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus, isBlueBubblesPrivateApiStatusEnabled, @@ -262,12 +262,7 @@ export async function sendBlueBubblesAttachment(params: { timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads }); - if (!res.ok) { - const errorText = await res.text(); - throw new Error( - `BlueBubbles attachment send failed (${res.status}): ${errorText || "unknown"}`, - ); - } + await assertMultipartActionOk(res, "attachment send"); const responseBody = await res.text(); if (!responseBody) { diff --git a/extensions/bluebubbles/src/chat.test.ts b/extensions/bluebubbles/src/chat.test.ts index cc37829bc9d..f8adc9b86fd 100644 --- a/extensions/bluebubbles/src/chat.test.ts +++ b/extensions/bluebubbles/src/chat.test.ts @@ -29,6 +29,11 @@ describe("chat", () => { }); } + function mockTwoOkTextResponses() { + mockOkTextResponse(); + mockOkTextResponse(); + } + async function expectCalledUrlIncludesPassword(params: { password: string; invoke: () => Promise; @@ -198,15 +203,7 @@ describe("chat", () => { }); it("uses POST for start and DELETE for stop", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); + mockTwoOkTextResponses(); await sendBlueBubblesTyping("iMessage;-;+15551234567", true, { serverUrl: "http://localhost:1234", @@ -442,15 +439,7 @@ describe("chat", () => { }); it("adds and removes participant using matching endpoint", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }) - .mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); + mockTwoOkTextResponses(); await addBlueBubblesParticipant("chat-guid", "+15551234567", { serverUrl: "http://localhost:1234", diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index b63f09272f2..17340b7f980 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles"; import { resolveBlueBubblesServerAccount } from "./account-resolve.js"; -import { postMultipartFormData } from "./multipart.js"; +import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js"; import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -55,12 +55,7 @@ async function sendBlueBubblesChatEndpointRequest(params: { { method: params.method }, params.opts.timeoutMs, ); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, - ); - } + await assertMultipartActionOk(res, params.action); } async function sendPrivateApiJsonRequest(params: { @@ -86,12 +81,7 @@ async function sendPrivateApiJsonRequest(params: { } const res = await blueBubblesFetchWithTimeout(url, request, params.opts.timeoutMs); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error( - `BlueBubbles ${params.action} failed (${res.status}): ${errorText || "unknown"}`, - ); - } + await assertMultipartActionOk(res, params.action); } export async function markBlueBubblesChatRead( @@ -329,8 +319,5 @@ export async function setGroupIconBlueBubbles( timeoutMs: opts.timeoutMs ?? 60_000, // longer timeout for file uploads }); - if (!res.ok) { - const errorText = await res.text().catch(() => ""); - throw new Error(`BlueBubbles setGroupIcon failed (${res.status}): ${errorText || "unknown"}`); - } + await assertMultipartActionOk(res, "setGroupIcon"); } diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts index 9f065599bfb..59fe82cbeae 100644 --- a/extensions/bluebubbles/src/media-send.test.ts +++ b/extensions/bluebubbles/src/media-send.test.ts @@ -70,6 +70,70 @@ async function makeTempDir(): Promise { return dir; } +async function makeTempFile( + fileName: string, + contents: string, + dir?: string, +): Promise<{ dir: string; filePath: string }> { + const resolvedDir = dir ?? (await makeTempDir()); + const filePath = path.join(resolvedDir, fileName); + await fs.writeFile(filePath, contents, "utf8"); + return { dir: resolvedDir, filePath }; +} + +async function sendLocalMedia(params: { + cfg: OpenClawConfig; + mediaPath: string; + accountId?: string; +}) { + return sendBlueBubblesMedia({ + cfg: params.cfg, + to: "chat:123", + accountId: params.accountId, + mediaPath: params.mediaPath, + }); +} + +async function expectRejectedLocalMedia(params: { + cfg: OpenClawConfig; + mediaPath: string; + error: RegExp; + accountId?: string; +}) { + await expect( + sendLocalMedia({ + cfg: params.cfg, + mediaPath: params.mediaPath, + accountId: params.accountId, + }), + ).rejects.toThrow(params.error); + + expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); +} + +async function expectAllowedLocalMedia(params: { + cfg: OpenClawConfig; + mediaPath: string; + expectedAttachment: Record; + accountId?: string; + expectMimeDetection?: boolean; +}) { + const result = await sendLocalMedia({ + cfg: params.cfg, + mediaPath: params.mediaPath, + accountId: params.accountId, + }); + + expect(result).toEqual({ messageId: "msg-1" }); + expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); + expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( + expect.objectContaining(params.expectedAttachment), + ); + if (params.expectMimeDetection) { + expect(runtimeMocks.detectMime).toHaveBeenCalled(); + } +} + beforeEach(() => { const runtime = createMockRuntime(); runtimeMocks = runtime.mocks; @@ -110,57 +174,43 @@ describe("sendBlueBubblesMedia local-path hardening", () => { const outsideFile = path.join(outsideDir, "outside.txt"); await fs.writeFile(outsideFile, "not allowed", "utf8"); - await expect( - sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - to: "chat:123", - mediaPath: outsideFile, - }), - ).rejects.toThrow(/not under any configured mediaLocalRoots/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); + await expectRejectedLocalMedia({ + cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), + mediaPath: outsideFile, + error: /not under any configured mediaLocalRoots/i, + }); }); it("allows local paths that are explicitly configured", async () => { - const allowedRoot = await makeTempDir(); - const allowedFile = path.join(allowedRoot, "allowed.txt"); - await fs.writeFile(allowedFile, "allowed", "utf8"); + const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile( + "allowed.txt", + "allowed", + ); - const result = await sendBlueBubblesMedia({ + await expectAllowedLocalMedia({ cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - to: "chat:123", mediaPath: allowedFile, - }); - - expect(result).toEqual({ messageId: "msg-1" }); - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); - expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ + expectedAttachment: { filename: "allowed.txt", contentType: "text/plain", - }), - ); - expect(runtimeMocks.detectMime).toHaveBeenCalled(); + }, + expectMimeDetection: true, + }); }); it("allows file:// media paths and file:// local roots", async () => { - const allowedRoot = await makeTempDir(); - const allowedFile = path.join(allowedRoot, "allowed.txt"); - await fs.writeFile(allowedFile, "allowed", "utf8"); - - const result = await sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }), - to: "chat:123", - mediaPath: pathToFileURL(allowedFile).toString(), - }); - - expect(result).toEqual({ messageId: "msg-1" }); - expect(sendBlueBubblesAttachmentMock).toHaveBeenCalledTimes(1); - expect(sendBlueBubblesAttachmentMock.mock.calls[0]?.[0]).toEqual( - expect.objectContaining({ - filename: "allowed.txt", - }), + const { dir: allowedRoot, filePath: allowedFile } = await makeTempFile( + "allowed.txt", + "allowed", ); + + await expectAllowedLocalMedia({ + cfg: createConfig({ mediaLocalRoots: [pathToFileURL(allowedRoot).toString()] }), + mediaPath: pathToFileURL(allowedFile).toString(), + expectedAttachment: { + filename: "allowed.txt", + }, + }); }); it("uses account-specific mediaLocalRoots over top-level roots", async () => { @@ -213,15 +263,11 @@ describe("sendBlueBubblesMedia local-path hardening", () => { return; } - await expect( - sendBlueBubblesMedia({ - cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), - to: "chat:123", - mediaPath: linkPath, - }), - ).rejects.toThrow(/not under any configured mediaLocalRoots/i); - - expect(sendBlueBubblesAttachmentMock).not.toHaveBeenCalled(); + await expectRejectedLocalMedia({ + cfg: createConfig({ mediaLocalRoots: [allowedRoot] }), + mediaPath: linkPath, + error: /not under any configured mediaLocalRoots/i, + }); }); it("rejects relative mediaLocalRoots entries", async () => { diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts index 3e06302593c..62651279237 100644 --- a/extensions/bluebubbles/src/monitor-normalize.test.ts +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from "vitest"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; +function createFallbackDmPayload(overrides: Record = {}) { + return { + guid: "msg-1", + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + ...overrides, + }; +} + describe("normalizeWebhookMessage", () => { it("falls back to DM chatGuid handle when sender handle is missing", () => { const result = normalizeWebhookMessage({ type: "new-message", - data: { - guid: "msg-1", + data: createFallbackDmPayload({ text: "hello", - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); @@ -78,15 +84,11 @@ describe("normalizeWebhookReaction", () => { it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { const result = normalizeWebhookReaction({ type: "updated-message", - data: { + data: createFallbackDmPayload({ guid: "msg-2", associatedMessageGuid: "p:0/msg-1", associatedMessageType: 2000, - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); diff --git a/extensions/bluebubbles/src/monitor-normalize.ts b/extensions/bluebubbles/src/monitor-normalize.ts index 83454602d4c..085bd8923e1 100644 --- a/extensions/bluebubbles/src/monitor-normalize.ts +++ b/extensions/bluebubbles/src/monitor-normalize.ts @@ -582,6 +582,29 @@ export function parseTapbackText(params: { return null; } + const parseLeadingReactionAction = ( + prefix: "reacted" | "removed", + defaultAction: "added" | "removed", + ) => { + if (!lower.startsWith(prefix)) { + return null; + } + const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; + if (!emoji) { + return null; + } + const quotedText = extractQuotedTapbackText(trimmed); + if (params.requireQuoted && !quotedText) { + return null; + } + const fallback = trimmed.slice(prefix.length).trim(); + return { + emoji, + action: params.actionHint ?? defaultAction, + quotedText: quotedText ?? fallback, + }; + }; + for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) { if (lower.startsWith(pattern)) { // Extract quoted text if present (e.g., 'Loved "hello"' -> "hello") @@ -599,30 +622,14 @@ export function parseTapbackText(params: { } } - if (lower.startsWith("reacted")) { - const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; - if (!emoji) { - return null; - } - const quotedText = extractQuotedTapbackText(trimmed); - if (params.requireQuoted && !quotedText) { - return null; - } - const fallback = trimmed.slice("reacted".length).trim(); - return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback }; + const reacted = parseLeadingReactionAction("reacted", "added"); + if (reacted) { + return reacted; } - if (lower.startsWith("removed")) { - const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint; - if (!emoji) { - return null; - } - const quotedText = extractQuotedTapbackText(trimmed); - if (params.requireQuoted && !quotedText) { - return null; - } - const fallback = trimmed.slice("removed".length).trim(); - return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback }; + const removed = parseLeadingReactionAction("removed", "removed"); + if (removed) { + return removed; } return null; } diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts index 7a6a29353bd..f6826ac510b 100644 --- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts +++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts @@ -302,65 +302,102 @@ describe("BlueBubbles webhook monitor", () => { }; } - describe("webhook parsing + auth handling", () => { - it("rejects non-POST requests", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); + async function dispatchWebhook(req: IncomingMessage) { + const res = createMockResponse(); + const handled = await handleBlueBubblesWebhookRequest(req, res); + return { handled, res }; + } - unregister = registerBlueBubblesWebhookTarget({ + function createWebhookRequestForTest(params?: { + method?: string; + url?: string; + body?: unknown; + headers?: Record; + remoteAddress?: string; + }) { + const req = createMockRequest( + params?.method ?? "POST", + params?.url ?? "/bluebubbles-webhook", + params?.body ?? {}, + params?.headers, + ); + if (params?.remoteAddress) { + setRequestRemoteAddress(req, params.remoteAddress); + } + return req; + } + + function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") { + const req = new EventEmitter() as IncomingMessage; + const destroyMock = vi.fn(); + req.method = "POST"; + req.url = url; + req.headers = {}; + req.destroy = destroyMock as unknown as IncomingMessage["destroy"]; + setRequestRemoteAddress(req, "127.0.0.1"); + return { req, destroyMock }; + } + + function registerWebhookTargets( + params: Array<{ + account: ResolvedBlueBubblesAccount; + statusSink?: (event: unknown) => void; + }>, + ) { + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + const unregisterFns = params.map(({ account, statusSink }) => + registerBlueBubblesWebhookTarget({ account, config, runtime: { log: vi.fn(), error: vi.fn() }, core, path: "/bluebubbles-webhook", - }); + statusSink, + }), + ); - const req = createMockRequest("GET", "/bluebubbles-webhook", {}); - const res = createMockResponse(); + unregister = () => { + for (const unregisterFn of unregisterFns) { + unregisterFn(); + } + }; + } - const handled = await handleBlueBubblesWebhookRequest(req, res); + async function expectWebhookStatus( + req: IncomingMessage, + expectedStatus: number, + expectedBody?: string, + ) { + const { handled, res } = await dispatchWebhook(req); + expect(handled).toBe(true); + expect(res.statusCode).toBe(expectedStatus); + if (expectedBody !== undefined) { + expect(res.body).toBe(expectedBody); + } + return res; + } - expect(handled).toBe(true); - expect(res.statusCode).toBe(405); + describe("webhook parsing + auth handling", () => { + it("rejects non-POST requests", async () => { + setupWebhookTarget(); + const req = createWebhookRequestForTest({ method: "GET" }); + await expectWebhookStatus(req, 405); }); it("accepts POST requests with valid JSON payload", async () => { setupWebhookTarget(); const payload = createNewMessagePayload({ date: Date.now() }); - - const req = createMockRequest("POST", "/bluebubbles-webhook", payload); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("ok"); + const req = createWebhookRequestForTest({ body: payload }); + await expectWebhookStatus(req, 200, "ok"); }); it("rejects requests with invalid JSON", async () => { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = createMockRequest("POST", "/bluebubbles-webhook", "invalid json {{"); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(400); + setupWebhookTarget(); + const req = createWebhookRequestForTest({ body: "invalid json {{" }); + await expectWebhookStatus(req, 400); }); it("accepts URL-encoded payload wrappers", async () => { @@ -369,42 +406,17 @@ describe("BlueBubbles webhook monitor", () => { const encodedBody = new URLSearchParams({ payload: JSON.stringify(payload), }).toString(); - - const req = createMockRequest("POST", "/bluebubbles-webhook", encodedBody); - const res = createMockResponse(); - - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); - expect(res.body).toBe("ok"); + const req = createWebhookRequestForTest({ body: encodedBody }); + await expectWebhookStatus(req, 200, "ok"); }); it("returns 408 when request body times out (Slow-Loris protection)", async () => { vi.useFakeTimers(); try { - const account = createMockAccount(); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + setupWebhookTarget(); // Create a request that never sends data or ends (simulates slow-loris) - const req = new EventEmitter() as IncomingMessage; - req.method = "POST"; - req.url = "/bluebubbles-webhook?password=test-password"; - req.headers = {}; - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - req.destroy = vi.fn(); + const { req, destroyMock } = createHangingWebhookRequest(); const res = createMockResponse(); @@ -416,7 +428,7 @@ describe("BlueBubbles webhook monitor", () => { const handled = await handledPromise; expect(handled).toBe(true); expect(res.statusCode).toBe(408); - expect(req.destroy).toHaveBeenCalled(); + expect(destroyMock).toHaveBeenCalled(); } finally { vi.useRealTimers(); } @@ -424,140 +436,62 @@ describe("BlueBubbles webhook monitor", () => { it("rejects unauthorized requests before reading the body", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); - - const req = new EventEmitter() as IncomingMessage; - req.method = "POST"; - req.url = "/bluebubbles-webhook?password=wrong-token"; - req.headers = {}; + setupWebhookTarget({ account }); + const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token"); const onSpy = vi.spyOn(req, "on"); - (req as unknown as { socket: { remoteAddress: string } }).socket = { - remoteAddress: "127.0.0.1", - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + await expectWebhookStatus(req, 401); expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function)); }); it("authenticates via password query parameter", async () => { const account = createMockAccount({ password: "secret-token" }); - - // Mock non-localhost request - const req = createMockRequest( - "POST", - "/bluebubbles-webhook?password=secret-token", - createNewMessagePayload(), - ); - setRequestRemoteAddress(req, "192.168.1.100"); setupWebhookTarget({ account }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=secret-token", + body: createNewMessagePayload(), + remoteAddress: "192.168.1.100", + }); + await expectWebhookStatus(req, 200); }); it("authenticates via x-password header", async () => { const account = createMockAccount({ password: "secret-token" }); - - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - createNewMessagePayload(), - { "x-password": "secret-token" }, // pragma: allowlist secret - ); - setRequestRemoteAddress(req, "192.168.1.100"); setupWebhookTarget({ account }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + const req = createWebhookRequestForTest({ + body: createNewMessagePayload(), + headers: { "x-password": "secret-token" }, // pragma: allowlist secret + remoteAddress: "192.168.1.100", + }); + await expectWebhookStatus(req, 200); }); it("rejects unauthorized requests with wrong password", async () => { const account = createMockAccount({ password: "secret-token" }); - const req = createMockRequest( - "POST", - "/bluebubbles-webhook?password=wrong-token", - createNewMessagePayload(), - ); - setRequestRemoteAddress(req, "192.168.1.100"); setupWebhookTarget({ account }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=wrong-token", + body: createNewMessagePayload(), + remoteAddress: "192.168.1.100", + }); + await expectWebhookStatus(req, 401); }); it("rejects ambiguous routing when multiple targets match the same password", async () => { const accountA = createMockAccount({ password: "secret-token" }); const accountB = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - const sinkA = vi.fn(); const sinkB = vi.fn(); + registerWebhookTargets([ + { account: accountA, statusSink: sinkA }, + { account: accountB, statusSink: sinkB }, + ]); - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=secret-token", + body: createNewMessagePayload(), remoteAddress: "192.168.1.100", - }; - - const unregisterA = registerBlueBubblesWebhookTarget({ - account: accountA, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkA, }); - const unregisterB = registerBlueBubblesWebhookTarget({ - account: accountB, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkB, - }); - unregister = () => { - unregisterA(); - unregisterB(); - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + await expectWebhookStatus(req, 401); expect(sinkA).not.toHaveBeenCalled(); expect(sinkB).not.toHaveBeenCalled(); }); @@ -565,107 +499,38 @@ describe("BlueBubbles webhook monitor", () => { it("ignores targets without passwords when a password-authenticated target matches", async () => { const accountStrict = createMockAccount({ password: "secret-token" }); const accountWithoutPassword = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - const sinkStrict = vi.fn(); const sinkWithoutPassword = vi.fn(); + registerWebhookTargets([ + { account: accountStrict, statusSink: sinkStrict }, + { account: accountWithoutPassword, statusSink: sinkWithoutPassword }, + ]); - const req = createMockRequest("POST", "/bluebubbles-webhook?password=secret-token", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { + const req = createWebhookRequestForTest({ + url: "/bluebubbles-webhook?password=secret-token", + body: createNewMessagePayload(), remoteAddress: "192.168.1.100", - }; - - const unregisterStrict = registerBlueBubblesWebhookTarget({ - account: accountStrict, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkStrict, }); - const unregisterNoPassword = registerBlueBubblesWebhookTarget({ - account: accountWithoutPassword, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - statusSink: sinkWithoutPassword, - }); - unregister = () => { - unregisterStrict(); - unregisterNoPassword(); - }; - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - - expect(handled).toBe(true); - expect(res.statusCode).toBe(200); + await expectWebhookStatus(req, 200); expect(sinkStrict).toHaveBeenCalledTimes(1); expect(sinkWithoutPassword).not.toHaveBeenCalled(); }); it("requires authentication for loopback requests when password is configured", async () => { const account = createMockAccount({ password: "secret-token" }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); + setupWebhookTarget({ account }); for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) { - const req = createMockRequest("POST", "/bluebubbles-webhook", { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }); - (req as unknown as { socket: { remoteAddress: string } }).socket = { + const req = createWebhookRequestForTest({ + body: createNewMessagePayload(), remoteAddress, - }; - - const loopbackUnregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", }); - - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); - - loopbackUnregister(); + await expectWebhookStatus(req, 401); } }); it("rejects targets without passwords for loopback and proxied-looking requests", async () => { const account = createMockAccount({ password: undefined }); - const config: OpenClawConfig = {}; - const core = createMockRuntime(); - setBlueBubblesRuntime(core); - - unregister = registerBlueBubblesWebhookTarget({ - account, - config, - runtime: { log: vi.fn(), error: vi.fn() }, - core, - path: "/bluebubbles-webhook", - }); + setupWebhookTarget({ account }); const headerVariants: Record[] = [ { host: "localhost" }, @@ -673,28 +538,12 @@ describe("BlueBubbles webhook monitor", () => { { host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" }, ]; for (const headers of headerVariants) { - const req = createMockRequest( - "POST", - "/bluebubbles-webhook", - { - type: "new-message", - data: { - text: "hello", - handle: { address: "+15551234567" }, - isGroup: false, - isFromMe: false, - guid: "msg-1", - }, - }, + const req = createWebhookRequestForTest({ + body: createNewMessagePayload(), headers, - ); - (req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1", - }; - const res = createMockResponse(); - const handled = await handleBlueBubblesWebhookRequest(req, res); - expect(handled).toBe(true); - expect(res.statusCode).toBe(401); + }); + await expectWebhookStatus(req, 401); } }); diff --git a/extensions/bluebubbles/src/multipart.ts b/extensions/bluebubbles/src/multipart.ts index 851cca016b7..e7c840745bb 100644 --- a/extensions/bluebubbles/src/multipart.ts +++ b/extensions/bluebubbles/src/multipart.ts @@ -30,3 +30,11 @@ export async function postMultipartFormData(params: { params.timeoutMs, ); } + +export async function assertMultipartActionOk(response: Response, action: string): Promise { + if (response.ok) { + return; + } + const errorText = await response.text().catch(() => ""); + throw new Error(`BlueBubbles ${action} failed (${response.status}): ${errorText || "unknown"}`); +} diff --git a/extensions/bluebubbles/src/reactions.test.ts b/extensions/bluebubbles/src/reactions.test.ts index 419ccc81e45..0b55337b35c 100644 --- a/extensions/bluebubbles/src/reactions.test.ts +++ b/extensions/bluebubbles/src/reactions.test.ts @@ -19,7 +19,7 @@ describe("reactions", () => { }); describe("sendBlueBubblesReaction", () => { - async function expectRemovedReaction(emoji: string) { + async function expectRemovedReaction(emoji: string, expectedReaction = "-love") { mockFetch.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(""), @@ -37,7 +37,7 @@ describe("reactions", () => { }); const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-love"); + expect(body.reaction).toBe(expectedReaction); } it("throws when chatGuid is empty", async () => { @@ -327,45 +327,11 @@ describe("reactions", () => { describe("reaction removal aliases", () => { it("handles emoji-based removal", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "👍", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-like"); + await expectRemovedReaction("👍", "-like"); }); it("handles text alias removal", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - text: () => Promise.resolve(""), - }); - - await sendBlueBubblesReaction({ - chatGuid: "chat-123", - messageGuid: "msg-123", - emoji: "haha", - remove: true, - opts: { - serverUrl: "http://localhost:1234", - password: "test", - }, - }); - - const body = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(body.reaction).toBe("-laugh"); + await expectRemovedReaction("haha", "-laugh"); }); }); }); diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json index 56f6c1085ee..fdab55b3da8 100644 --- a/extensions/copilot-proxy/package.json +++ b/extensions/copilot-proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/copilot-proxy", - "version": "2026.3.11", + "version": "2026.3.14", "private": true, "description": "OpenClaw Copilot Proxy provider plugin", "type": "module", diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 7590703a32b..7ba88842a7a 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -2,6 +2,7 @@ import os from "node:os"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/device-pair"; import { approveDevicePairing, + issueDeviceBootstrapToken, listDevicePairing, resolveGatewayBindUrl, runPluginCommandWithTimeout, @@ -31,8 +32,7 @@ type DevicePairPluginConfig = { type SetupPayload = { url: string; - token?: string; - password?: string; + bootstrapToken: string; }; type ResolveUrlResult = { @@ -41,10 +41,8 @@ type ResolveUrlResult = { error?: string; }; -type ResolveAuthResult = { - token?: string; - password?: string; - label?: string; +type ResolveAuthLabelResult = { + label?: "token" | "password"; error?: string; }; @@ -110,13 +108,21 @@ function resolveScheme( return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; } -function isPrivateIPv4(address: string): boolean { +function parseIPv4Octets(address: string): [number, number, number, number] | null { const parts = address.split("."); - if (parts.length != 4) { - return false; + if (parts.length !== 4) { + return null; } const octets = parts.map((part) => Number.parseInt(part, 10)); if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + return null; + } + return octets as [number, number, number, number]; +} + +function isPrivateIPv4(address: string): boolean { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; @@ -133,12 +139,8 @@ function isPrivateIPv4(address: string): boolean { } function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; @@ -187,7 +189,7 @@ async function resolveTailnetHost(): Promise { ); } -function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { +function resolveAuthLabel(cfg: OpenClawPluginApi["config"]): ResolveAuthLabelResult { const mode = cfg.gateway?.auth?.mode; const token = pickFirstDefined([ @@ -203,13 +205,13 @@ function resolveAuth(cfg: OpenClawPluginApi["config"]): ResolveAuthResult { ]) ?? undefined; if (mode === "token" || mode === "password") { - return resolveRequiredAuth(mode, { token, password }); + return resolveRequiredAuthLabel(mode, { token, password }); } if (token) { - return { token, label: "token" }; + return { label: "token" }; } if (password) { - return { password, label: "password" }; + return { label: "password" }; } return { error: "Gateway auth is not configured (no token or password)." }; } @@ -227,17 +229,17 @@ function pickFirstDefined(candidates: Array): string | null { return null; } -function resolveRequiredAuth( +function resolveRequiredAuthLabel( mode: "token" | "password", values: { token?: string; password?: string }, -): ResolveAuthResult { +): ResolveAuthLabelResult { if (mode === "token") { return values.token - ? { token: values.token, label: "token" } + ? { label: "token" } : { error: "Gateway auth is set to token, but no token is configured." }; } return values.password - ? { password: values.password, label: "password" } + ? { label: "password" } : { error: "Gateway auth is set to password, but no password is configured." }; } @@ -393,9 +395,9 @@ export default function register(api: OpenClawPluginApi) { return { text: `✅ Paired ${label}${platformLabel}.` }; } - const auth = resolveAuth(api.config); - if (auth.error) { - return { text: `Error: ${auth.error}` }; + const authLabelResult = resolveAuthLabel(api.config); + if (authLabelResult.error) { + return { text: `Error: ${authLabelResult.error}` }; } const urlResult = await resolveGatewayUrl(api); @@ -405,14 +407,13 @@ export default function register(api: OpenClawPluginApi) { const payload: SetupPayload = { url: urlResult.url, - token: auth.token, - password: auth.password, + bootstrapToken: (await issueDeviceBootstrapToken()).token, }; if (action === "qr") { const setupCode = encodeSetupCode(payload); const qrAscii = await renderQrAscii(setupCode); - const authLabel = auth.label ?? "auth"; + const authLabel = authLabelResult.label ?? "auth"; const channel = ctx.channel; const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; @@ -503,7 +504,7 @@ export default function register(api: OpenClawPluginApi) { const channel = ctx.channel; const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || ""; - const authLabel = auth.label ?? "auth"; + const authLabel = authLabelResult.label ?? "auth"; if (channel === "telegram" && target) { try { diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json index 91aea1e9256..b51ead550ef 100644 --- a/extensions/diagnostics-otel/package.json +++ b/extensions/diagnostics-otel/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diagnostics-otel", - "version": "2026.3.11", + "version": "2026.3.14", "description": "OpenClaw diagnostics OpenTelemetry exporter", "type": "module", "dependencies": { diff --git a/extensions/diffs/index.test.ts b/extensions/diffs/index.test.ts index df0a0a79192..c38da12bfcd 100644 --- a/extensions/diffs/index.test.ts +++ b/extensions/diffs/index.test.ts @@ -1,6 +1,8 @@ import type { IncomingMessage } from "node:http"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { describe, expect, it, vi } from "vitest"; import { createMockServerResponse } from "../../src/test-utils/mock-http-response.js"; +import { createTestPluginApi } from "../test-utils/plugin-api.js"; import plugin from "./index.js"; describe("diffs plugin registration", () => { @@ -9,33 +11,19 @@ describe("diffs plugin registration", () => { const registerHttpRoute = vi.fn(); const on = vi.fn(); - plugin.register?.({ - id: "diffs", - name: "Diffs", - description: "Diffs", - source: "test", - config: {}, - runtime: {} as never, - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool, - registerHook() {}, - registerHttpRoute, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on, - }); + plugin.register?.( + createTestPluginApi({ + id: "diffs", + name: "Diffs", + description: "Diffs", + source: "test", + config: {}, + runtime: {} as never, + registerTool, + registerHttpRoute, + on, + }), + ); expect(registerTool).toHaveBeenCalledTimes(1); expect(registerHttpRoute).toHaveBeenCalledTimes(1); @@ -55,17 +43,15 @@ describe("diffs plugin registration", () => { }); it("applies plugin-config defaults through registered tool and viewer handler", async () => { - let registeredTool: - | { execute?: (toolCallId: string, params: Record) => Promise } - | undefined; - let registeredHttpRouteHandler: - | (( - req: IncomingMessage, - res: ReturnType, - ) => Promise) - | undefined; + type RegisteredTool = { + execute?: (toolCallId: string, params: Record) => Promise; + }; + type RegisteredHttpRouteParams = Parameters[0]; - plugin.register?.({ + let registeredTool: RegisteredTool | undefined; + let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined; + + const api = createTestPluginApi({ id: "diffs", name: "Diffs", description: "Diffs", @@ -88,31 +74,16 @@ describe("diffs plugin registration", () => { }, }, runtime: {} as never, - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool(tool) { + registerTool(tool: Parameters[0]) { registeredTool = typeof tool === "function" ? undefined : tool; }, - registerHook() {}, - registerHttpRoute(params) { - registeredHttpRouteHandler = params.handler as typeof registeredHttpRouteHandler; + registerHttpRoute(params: RegisteredHttpRouteParams) { + registeredHttpRouteHandler = params.handler; }, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on() {}, }); + plugin.register?.(api as unknown as OpenClawPluginApi); + const result = await registeredTool?.execute?.("tool-1", { before: "one\n", after: "two\n", diff --git a/extensions/diffs/package.json b/extensions/diffs/package.json index c9e30cee333..b92b16052b8 100644 --- a/extensions/diffs/package.json +++ b/extensions/diffs/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/diffs", - "version": "2026.3.11", + "version": "2026.3.14", "private": true, "description": "OpenClaw diff viewer plugin", "type": "module", @@ -8,7 +8,7 @@ "build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js" }, "dependencies": { - "@pierre/diffs": "1.0.11", + "@pierre/diffs": "1.1.0", "@sinclair/typebox": "0.34.48", "playwright-core": "1.58.2" }, diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 43216580379..a1caef018e4 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -9,6 +9,19 @@ describe("createDiffsHttpHandler", () => { let store: DiffArtifactStore; let cleanupRootDir: () => Promise; + async function handleLocalGet(url: string) { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url, + }), + res, + ); + return { handled, res }; + } + beforeEach(async () => { ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); }); @@ -19,16 +32,7 @@ describe("createDiffsHttpHandler", () => { it("serves a stored diff document", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - }), - res, - ); + const { handled, res } = await handleLocalGet(artifact.viewerPath); expect(handled).toBe(true); expect(res.statusCode).toBe(200); @@ -38,15 +42,8 @@ describe("createDiffsHttpHandler", () => { it("rejects invalid tokens", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath.replace(artifact.token, "bad-token"), - }), - res, + const { handled, res } = await handleLocalGet( + artifact.viewerPath.replace(artifact.token, "bad-token"), ); expect(handled).toBe(true); diff --git a/extensions/diffs/src/render.test.ts b/extensions/diffs/src/render.test.ts index f46a2c9abe9..006b239a39f 100644 --- a/extensions/diffs/src/render.test.ts +++ b/extensions/diffs/src/render.test.ts @@ -23,8 +23,7 @@ describe("renderDiffDocument", () => { expect(rendered.html).toContain("data-openclaw-diff-root"); expect(rendered.html).toContain("src/example.ts"); expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).not.toContain("/plugins/diffs/assets/viewer.js"); - expect(rendered.imageHtml).toContain('data-openclaw-diffs-ready="true"'); + expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js"); expect(rendered.imageHtml).toContain("max-width: 960px;"); expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;"); expect(rendered.html).toContain("min-height: 100vh;"); diff --git a/extensions/diffs/src/render.ts b/extensions/diffs/src/render.ts index fb3d089c90a..364252c0b3b 100644 --- a/extensions/diffs/src/render.ts +++ b/extensions/diffs/src/render.ts @@ -1,5 +1,12 @@ -import type { FileContents, FileDiffMetadata, SupportedLanguages } from "@pierre/diffs"; -import { parsePatchFiles } from "@pierre/diffs"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import type { + FileContents, + FileDiffMetadata, + SupportedLanguages, + ThemeRegistrationResolved, +} from "@pierre/diffs"; +import { RegisteredCustomThemes, parsePatchFiles } from "@pierre/diffs"; import { preloadFileDiff, preloadMultiFileDiff } from "@pierre/diffs/ssr"; import type { DiffInput, @@ -13,6 +20,45 @@ import { VIEWER_LOADER_PATH } from "./viewer-assets.js"; const DEFAULT_FILE_NAME = "diff.txt"; const MAX_PATCH_FILE_COUNT = 128; const MAX_PATCH_TOTAL_LINES = 120_000; +const diffsRequire = createRequire(import.meta.resolve("@pierre/diffs")); + +let pierreThemesPatched = false; + +function createThemeLoader( + themeName: "pierre-dark" | "pierre-light", + themePath: string, +): () => Promise { + let cachedTheme: ThemeRegistrationResolved | undefined; + return async () => { + if (cachedTheme) { + return cachedTheme; + } + const raw = await fs.readFile(themePath, "utf8"); + const parsed = JSON.parse(raw) as Record; + cachedTheme = { + ...parsed, + name: themeName, + } as ThemeRegistrationResolved; + return cachedTheme; + }; +} + +function patchPierreThemeLoadersForNode24(): void { + if (pierreThemesPatched) { + return; + } + try { + const darkThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-dark.json"); + const lightThemePath = diffsRequire.resolve("@pierre/theme/themes/pierre-light.json"); + RegisteredCustomThemes.set("pierre-dark", createThemeLoader("pierre-dark", darkThemePath)); + RegisteredCustomThemes.set("pierre-light", createThemeLoader("pierre-light", lightThemePath)); + pierreThemesPatched = true; + } catch { + // Keep upstream loaders if theme files cannot be resolved. + } +} + +patchPierreThemeLoadersForNode24(); function escapeCssString(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); @@ -195,14 +241,6 @@ function renderDiffCard(payload: DiffViewerPayload): string { `; } -function renderStaticDiffCard(prerenderedHTML: string): string { - return `
- - - -
`; -} - function buildHtmlDocument(params: { title: string; bodyHtml: string; @@ -211,7 +249,7 @@ function buildHtmlDocument(params: { runtimeMode: "viewer" | "image"; }): string { return ` - + @@ -303,7 +341,7 @@ function buildHtmlDocument(params: { ${params.bodyHtml} - ${params.runtimeMode === "viewer" ? `` : ""} + `; } @@ -314,16 +352,12 @@ type RenderedSection = { }; function buildRenderedSection(params: { - viewerPrerenderedHtml: string; - imagePrerenderedHtml: string; - payload: Omit; + viewerPayload: DiffViewerPayload; + imagePayload: DiffViewerPayload; }): RenderedSection { return { - viewer: renderDiffCard({ - prerenderedHTML: params.viewerPrerenderedHtml, - ...params.payload, - }), - image: renderStaticDiffCard(params.imagePrerenderedHtml), + viewer: renderDiffCard(params.viewerPayload), + image: renderDiffCard(params.imagePayload), }; } @@ -355,21 +389,20 @@ async function renderBeforeAfterDiff( }; const { viewerOptions, imageOptions } = buildRenderVariants(options); const [viewerResult, imageResult] = await Promise.all([ - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: viewerOptions, }), - preloadMultiFileDiff({ + preloadMultiFileDiffWithFallback({ oldFile, newFile, options: imageOptions, }), ]); const section = buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, oldFile: viewerResult.oldFile, newFile: viewerResult.newFile, options: viewerOptions, @@ -378,6 +411,16 @@ async function renderBeforeAfterDiff( newFile: viewerResult.newFile, }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + options: imageOptions, + langs: buildPayloadLanguages({ + oldFile: imageResult.oldFile, + newFile: imageResult.newFile, + }), + }, }); return { @@ -410,24 +453,29 @@ async function renderPatchDiff( const sections = await Promise.all( files.map(async (fileDiff) => { const [viewerResult, imageResult] = await Promise.all([ - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: viewerOptions, }), - preloadFileDiff({ + preloadFileDiffWithFallback({ fileDiff, options: imageOptions, }), ]); return buildRenderedSection({ - viewerPrerenderedHtml: viewerResult.prerenderedHTML, - imagePrerenderedHtml: imageResult.prerenderedHTML, - payload: { + viewerPayload: { + prerenderedHTML: viewerResult.prerenderedHTML, fileDiff: viewerResult.fileDiff, options: viewerOptions, langs: buildPayloadLanguages({ fileDiff: viewerResult.fileDiff }), }, + imagePayload: { + prerenderedHTML: imageResult.prerenderedHTML, + fileDiff: imageResult.fileDiff, + options: imageOptions, + langs: buildPayloadLanguages({ fileDiff: imageResult.fileDiff }), + }, }); }), ); @@ -468,3 +516,49 @@ export async function renderDiffDocument( inputKind: input.kind, }; } + +type PreloadedFileDiffResult = Awaited>; +type PreloadedMultiFileDiffResult = Awaited>; + +function shouldFallbackToClientHydration(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message.includes('needs an import attribute of "type: json"') + ); +} + +async function preloadFileDiffWithFallback(params: { + fileDiff: FileDiffMetadata; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + fileDiff: params.fileDiff, + prerenderedHTML: "", + }; + } +} + +async function preloadMultiFileDiffWithFallback(params: { + oldFile: FileContents; + newFile: FileContents; + options: DiffViewerOptions; +}): Promise { + try { + return await preloadMultiFileDiff(params); + } catch (error) { + if (!shouldFallbackToClientHydration(error)) { + throw error; + } + return { + oldFile: params.oldFile, + newFile: params.newFile, + prerenderedHTML: "", + }; + } +} diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 416bdf8dc14..2f845727274 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diffs"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createTestPluginApi } from "../../test-utils/plugin-api.js"; import type { DiffScreenshotter } from "./browser.js"; import { DEFAULT_DIFFS_TOOL_DEFAULTS } from "./config.js"; import { DiffArtifactStore } from "./store.js"; @@ -56,7 +57,7 @@ describe("diffs tool", () => { const cleanupSpy = vi.spyOn(store, "scheduleCleanup"); const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ @@ -135,9 +136,7 @@ describe("diffs tool", () => { mode: "file", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("honors ttlSeconds for artifact-only file output", async () => { @@ -227,9 +226,7 @@ describe("diffs tool", () => { after: "two\n", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("falls back to view output when both mode cannot render an image", async () => { @@ -335,13 +332,13 @@ describe("diffs tool", () => { const html = await store.readHtml(id); expect(html).toContain('body data-theme="light"'); expect(html).toContain("--diffs-font-size: 17px;"); - expect(html).toContain('--diffs-font-family: "JetBrains Mono"'); + expect(html).toContain("JetBrains Mono"); }); it("prefers explicit tool params over configured defaults", async () => { const screenshotter = createPngScreenshotter({ assertHtml: (html) => { - expect(html).not.toContain("/plugins/diffs/assets/viewer.js"); + expect(html).toContain("/plugins/diffs/assets/viewer.js"); }, assertImage: (image) => { expect(image).toMatchObject({ @@ -387,7 +384,7 @@ describe("diffs tool", () => { }); function createApi(): OpenClawPluginApi { - return { + return createTestPluginApi({ id: "diffs", name: "Diffs", description: "Diffs", @@ -399,26 +396,7 @@ function createApi(): OpenClawPluginApi { }, }, runtime: {} as OpenClawPluginApi["runtime"], - logger: { - info() {}, - warn() {}, - error() {}, - }, - registerTool() {}, - registerHook() {}, - registerHttpRoute() {}, - registerChannel() {}, - registerGatewayMethod() {}, - registerCli() {}, - registerService() {}, - registerProvider() {}, - registerCommand() {}, - registerContextEngine() {}, - resolvePath(input: string) { - return input; - }, - on() {}, - }; + }) as OpenClawPluginApi; } function createToolWithScreenshotter( @@ -434,6 +412,15 @@ function createToolWithScreenshotter( }); } +function expectArtifactOnlyFileResult( + screenshotter: DiffScreenshotter, + result: { details?: unknown } | null | undefined, +) { + expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); + expect((result?.details as Record).mode).toBe("file"); + expect((result?.details as Record).viewerUrl).toBeUndefined(); +} + function createPngScreenshotter( params: { assertHtml?: (html: string) => void; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 7f291bd1c7a..a85eb37b85f 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/discord", - "version": "2026.3.11", + "version": "2026.3.14", "description": "OpenClaw Discord channel plugin", "type": "module", "openclaw": { diff --git a/src/discord/account-inspect.test.ts b/extensions/discord/src/account-inspect.test.ts similarity index 98% rename from src/discord/account-inspect.test.ts rename to extensions/discord/src/account-inspect.test.ts index 0e8303635f9..eda0b6cc0e0 100644 --- a/src/discord/account-inspect.test.ts +++ b/extensions/discord/src/account-inspect.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { inspectDiscordAccount } from "./account-inspect.js"; function asConfig(value: unknown): OpenClawConfig { diff --git a/src/discord/account-inspect.ts b/extensions/discord/src/account-inspect.ts similarity index 90% rename from src/discord/account-inspect.ts rename to extensions/discord/src/account-inspect.ts index 53357ffd636..d99f87aeb56 100644 --- a/src/discord/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,7 +1,10 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordAccountConfig } from "../config/types.discord.js"; -import { hasConfiguredSecretInput, normalizeSecretInputString } from "../config/types.secrets.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig } from "../../../src/config/types.discord.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId, diff --git a/src/discord/accounts.test.ts b/extensions/discord/src/accounts.test.ts similarity index 100% rename from src/discord/accounts.test.ts rename to extensions/discord/src/accounts.test.ts diff --git a/src/discord/accounts.ts b/extensions/discord/src/accounts.ts similarity index 86% rename from src/discord/accounts.ts rename to extensions/discord/src/accounts.ts index b4e71c78343..6cd1699f192 100644 --- a/src/discord/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,9 +1,9 @@ -import { createAccountActionGate } from "../channels/plugins/account-action-gate.js"; -import { createAccountListHelpers } from "../channels/plugins/account-helpers.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js"; +import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts new file mode 100644 index 00000000000..80cd97217ae --- /dev/null +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -0,0 +1,451 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + parseAvailableTags, + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { + isDiscordModerationAction, + readDiscordModerationCommand, +} from "../../../../src/agents/tools/discord-actions-moderation-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; + +type Ctx = Pick< + ChannelMessageActionContext, + "action" | "params" | "cfg" | "accountId" | "requesterSenderId" +>; + +export async function tryHandleDiscordMessageActionGuildAdmin(params: { + ctx: Ctx; + resolveChannelId: () => string; + readParentIdParam: (params: Record) => string | null | undefined; +}): Promise | undefined> { + const { ctx, resolveChannelId, readParentIdParam } = params; + const { action, params: actionParams, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId"); + + if (action === "member-info") { + const userId = readStringParam(actionParams, "userId", { required: true }); + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "memberInfo", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "role-info") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "roleInfo", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "emojiList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "emoji-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "emojiName", { required: true }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + const roleIds = readStringArrayParam(actionParams, "roleIds"); + return await handleDiscordAction( + { + action: "emojiUpload", + accountId: accountId ?? undefined, + guildId, + name, + mediaUrl, + roleIds, + }, + cfg, + ); + } + + if (action === "sticker-upload") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "stickerName", { + required: true, + }); + const description = readStringParam(actionParams, "stickerDesc", { + required: true, + }); + const tags = readStringParam(actionParams, "stickerTags", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { + required: true, + trim: false, + }); + return await handleDiscordAction( + { + action: "stickerUpload", + accountId: accountId ?? undefined, + guildId, + name, + description, + tags, + mediaUrl, + }, + cfg, + ); + } + + if (action === "role-add" || action === "role-remove") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + const roleId = readStringParam(actionParams, "roleId", { required: true }); + return await handleDiscordAction( + { + action: action === "role-add" ? "roleAdd" : "roleRemove", + accountId: accountId ?? undefined, + guildId, + userId, + roleId, + }, + cfg, + ); + } + + if (action === "channel-info") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelInfo", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "channel-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const type = readNumberParam(actionParams, "type", { integer: true }); + const parentId = readParentIdParam(actionParams); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + return await handleDiscordAction( + { + action: "channelCreate", + accountId: accountId ?? undefined, + guildId, + name, + type: type ?? undefined, + parentId: parentId ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + nsfw, + }, + cfg, + ); + } + + if (action === "channel-edit") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const topic = readStringParam(actionParams, "topic"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + const parentId = readParentIdParam(actionParams); + const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined; + const rateLimitPerUser = readNumberParam(actionParams, "rateLimitPerUser", { + integer: true, + }); + const archived = typeof actionParams.archived === "boolean" ? actionParams.archived : undefined; + const locked = typeof actionParams.locked === "boolean" ? actionParams.locked : undefined; + const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", { + integer: true, + }); + const availableTags = parseAvailableTags(actionParams.availableTags); + return await handleDiscordAction( + { + action: "channelEdit", + accountId: accountId ?? undefined, + channelId, + name: name ?? undefined, + topic: topic ?? undefined, + position: position ?? undefined, + parentId: parentId === undefined ? undefined : parentId, + nsfw, + rateLimitPerUser: rateLimitPerUser ?? undefined, + archived, + locked, + autoArchiveDuration: autoArchiveDuration ?? undefined, + availableTags, + }, + cfg, + ); + } + + if (action === "channel-delete") { + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + return await handleDiscordAction( + { action: "channelDelete", accountId: accountId ?? undefined, channelId }, + cfg, + ); + } + + if (action === "channel-move") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId", { + required: true, + }); + const parentId = readParentIdParam(actionParams); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "channelMove", + accountId: accountId ?? undefined, + guildId, + channelId, + parentId: parentId === undefined ? undefined : parentId, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "name", { required: true }); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryCreate", + accountId: accountId ?? undefined, + guildId, + name, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-edit") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + const name = readStringParam(actionParams, "name"); + const position = readNumberParam(actionParams, "position", { + integer: true, + }); + return await handleDiscordAction( + { + action: "categoryEdit", + accountId: accountId ?? undefined, + categoryId, + name: name ?? undefined, + position: position ?? undefined, + }, + cfg, + ); + } + + if (action === "category-delete") { + const categoryId = readStringParam(actionParams, "categoryId", { + required: true, + }); + return await handleDiscordAction( + { action: "categoryDelete", accountId: accountId ?? undefined, categoryId }, + cfg, + ); + } + + if (action === "voice-status") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const userId = readStringParam(actionParams, "userId", { required: true }); + return await handleDiscordAction( + { action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId }, + cfg, + ); + } + + if (action === "event-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + return await handleDiscordAction( + { action: "eventList", accountId: accountId ?? undefined, guildId }, + cfg, + ); + } + + if (action === "event-create") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const name = readStringParam(actionParams, "eventName", { required: true }); + const startTime = readStringParam(actionParams, "startTime", { + required: true, + }); + const endTime = readStringParam(actionParams, "endTime"); + const description = readStringParam(actionParams, "desc"); + const channelId = readStringParam(actionParams, "channelId"); + const location = readStringParam(actionParams, "location"); + const entityType = readStringParam(actionParams, "eventType"); + return await handleDiscordAction( + { + action: "eventCreate", + accountId: accountId ?? undefined, + guildId, + name, + startTime, + endTime, + description, + channelId, + location, + entityType, + }, + cfg, + ); + } + + if (isDiscordModerationAction(action)) { + const moderation = readDiscordModerationCommand(action, { + ...actionParams, + durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }), + deleteMessageDays: readNumberParam(actionParams, "deleteDays", { + integer: true, + }), + }); + const senderUserId = ctx.requesterSenderId?.trim() || undefined; + return await handleDiscordAction( + { + action: moderation.action, + accountId: accountId ?? undefined, + guildId: moderation.guildId, + userId: moderation.userId, + durationMinutes: moderation.durationMinutes, + until: moderation.until, + reason: moderation.reason, + deleteMessageDays: moderation.deleteMessageDays, + senderUserId, + }, + cfg, + ); + } + + // Some actions are conceptually "admin", but still act on a resolved channel. + if (action === "thread-list") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const channelId = readStringParam(actionParams, "channelId"); + const includeArchived = + typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined; + const before = readStringParam(actionParams, "before"); + const limit = readNumberParam(actionParams, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "threadList", + accountId: accountId ?? undefined, + guildId, + channelId, + includeArchived, + before, + limit, + }, + cfg, + ); + } + + if (action === "thread-reply") { + const content = readStringParam(actionParams, "message", { + required: true, + }); + const mediaUrl = readStringParam(actionParams, "media", { trim: false }); + const replyTo = readStringParam(actionParams, "replyTo"); + + // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`. + // Prefer `threadId` when present to avoid accidentally replying in the parent channel. + const threadId = readStringParam(actionParams, "threadId"); + const channelId = threadId ?? resolveChannelId(); + + return await handleDiscordAction( + { + action: "threadReply", + accountId: accountId ?? undefined, + channelId, + content, + mediaUrl: mediaUrl ?? undefined, + replyTo: replyTo ?? undefined, + }, + cfg, + ); + } + + if (action === "search") { + const guildId = readStringParam(actionParams, "guildId", { + required: true, + }); + const query = readStringParam(actionParams, "query", { required: true }); + return await handleDiscordAction( + { + action: "searchMessages", + accountId: accountId ?? undefined, + guildId, + content: query, + channelId: readStringParam(actionParams, "channelId"), + channelIds: readStringArrayParam(actionParams, "channelIds"), + authorId: readStringParam(actionParams, "authorId"), + authorIds: readStringArrayParam(actionParams, "authorIds"), + limit: readNumberParam(actionParams, "limit", { integer: true }), + }, + cfg, + ); + } + + return undefined; +} diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts new file mode 100644 index 00000000000..b0842ce25b2 --- /dev/null +++ b/extensions/discord/src/actions/handle-action.ts @@ -0,0 +1,295 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "../../../../src/agents/tools/common.js"; +import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-actions-shared.js"; +import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js"; +import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js"; +import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js"; +import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js"; +import { resolveDiscordChannelId } from "../targets.js"; +import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; + +const providerId = "discord"; + +export async function handleDiscordMessageAction( + ctx: Pick< + ChannelMessageActionContext, + | "action" + | "params" + | "cfg" + | "accountId" + | "requesterSenderId" + | "toolContext" + | "mediaLocalRoots" + >, +): Promise> { + const { action, params, cfg } = ctx; + const accountId = ctx.accountId ?? readStringParam(params, "accountId"); + const actionOptions = { + mediaLocalRoots: ctx.mediaLocalRoots, + } as const; + + const resolveChannelId = () => + resolveDiscordChannelId( + readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }), + ); + + if (action === "send") { + const to = readStringParam(params, "to", { required: true }); + const asVoice = readBooleanParam(params, "asVoice") === true; + const rawComponents = params.components; + const hasComponents = + Boolean(rawComponents) && + (typeof rawComponents === "function" || typeof rawComponents === "object"); + const components = hasComponents ? rawComponents : undefined; + const content = readStringParam(params, "message", { + required: !asVoice && !hasComponents, + allowEmpty: true, + }); + // Support media, path, and filePath for media URL + const mediaUrl = + readStringParam(params, "media", { trim: false }) ?? + readStringParam(params, "path", { trim: false }) ?? + readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); + const replyTo = readStringParam(params, "replyTo"); + const rawEmbeds = params.embeds; + const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; + const silent = readBooleanParam(params, "silent") === true; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); + return await handleDiscordAction( + { + action: "sendMessage", + accountId: accountId ?? undefined, + to, + content, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + replyTo: replyTo ?? undefined, + components, + embeds, + asVoice, + silent, + __sessionKey: sessionKey ?? undefined, + __agentId: agentId ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "poll") { + const to = readStringParam(params, "to", { required: true }); + const question = readStringParam(params, "pollQuestion", { + required: true, + }); + const answers = readStringArrayParam(params, "pollOption", { required: true }); + const allowMultiselect = readBooleanParam(params, "pollMulti"); + const durationHours = readNumberParam(params, "pollDurationHours", { + integer: true, + strict: true, + }); + return await handleDiscordAction( + { + action: "poll", + accountId: accountId ?? undefined, + to, + question, + answers, + allowMultiselect, + durationHours: durationHours ?? undefined, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "react") { + const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext }); + const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : ""; + if (!messageId) { + throw new Error( + "messageId required. Provide messageId explicitly or react to the current inbound message.", + ); + } + const emoji = readStringParam(params, "emoji", { allowEmpty: true }); + const remove = readBooleanParam(params, "remove"); + return await handleDiscordAction( + { + action: "react", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + emoji, + remove, + }, + cfg, + actionOptions, + ); + } + + if (action === "reactions") { + const messageId = readStringParam(params, "messageId", { required: true }); + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "reactions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + limit, + }, + cfg, + actionOptions, + ); + } + + if (action === "read") { + const limit = readNumberParam(params, "limit", { integer: true }); + return await handleDiscordAction( + { + action: "readMessages", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + limit, + before: readStringParam(params, "before"), + after: readStringParam(params, "after"), + around: readStringParam(params, "around"), + }, + cfg, + actionOptions, + ); + } + + if (action === "edit") { + const messageId = readStringParam(params, "messageId", { required: true }); + const content = readStringParam(params, "message", { required: true }); + return await handleDiscordAction( + { + action: "editMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + content, + }, + cfg, + actionOptions, + ); + } + + if (action === "delete") { + const messageId = readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: "deleteMessage", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "pin" || action === "unpin" || action === "list-pins") { + const messageId = + action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true }); + return await handleDiscordAction( + { + action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + messageId, + }, + cfg, + actionOptions, + ); + } + + if (action === "permissions") { + return await handleDiscordAction( + { + action: "permissions", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + }, + cfg, + actionOptions, + ); + } + + if (action === "thread-create") { + const name = readStringParam(params, "threadName", { required: true }); + const messageId = readStringParam(params, "messageId"); + const content = readStringParam(params, "message"); + const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", { + integer: true, + }); + const appliedTags = readStringArrayParam(params, "appliedTags"); + return await handleDiscordAction( + { + action: "threadCreate", + accountId: accountId ?? undefined, + channelId: resolveChannelId(), + name, + messageId, + content, + autoArchiveMinutes, + appliedTags: appliedTags ?? undefined, + }, + cfg, + actionOptions, + ); + } + + if (action === "sticker") { + const stickerIds = + readStringArrayParam(params, "stickerId", { + required: true, + label: "sticker-id", + }) ?? []; + return await handleDiscordAction( + { + action: "sticker", + accountId: accountId ?? undefined, + to: readStringParam(params, "to", { required: true }), + stickerIds, + content: readStringParam(params, "message"), + }, + cfg, + actionOptions, + ); + } + + if (action === "set-presence") { + return await handleDiscordAction( + { + action: "setPresence", + accountId: accountId ?? undefined, + status: readStringParam(params, "status"), + activityType: readStringParam(params, "activityType"), + activityName: readStringParam(params, "activityName"), + activityUrl: readStringParam(params, "activityUrl"), + activityState: readStringParam(params, "activityState"), + }, + cfg, + actionOptions, + ); + } + + const adminResult = await tryHandleDiscordMessageActionGuildAdmin({ + ctx, + resolveChannelId, + readParentIdParam: readDiscordParentIdParam, + }); + if (adminResult !== undefined) { + return adminResult; + } + + throw new Error(`Action ${String(action)} is not supported for provider ${providerId}.`); +} diff --git a/src/discord/api.test.ts b/extensions/discord/src/api.test.ts similarity index 96% rename from src/discord/api.test.ts rename to extensions/discord/src/api.test.ts index 4c9f1a9c0c1..5b0e648aa1d 100644 --- a/src/discord/api.test.ts +++ b/extensions/discord/src/api.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; +import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js"; import { fetchDiscord } from "./api.js"; import { jsonResponse } from "./test-http-helpers.js"; diff --git a/src/discord/api.ts b/extensions/discord/src/api.ts similarity index 97% rename from src/discord/api.ts rename to extensions/discord/src/api.ts index f8a88a50252..cead5eb8cea 100644 --- a/src/discord/api.ts +++ b/extensions/discord/src/api.ts @@ -1,5 +1,5 @@ -import { resolveFetch } from "../infra/fetch.js"; -import { resolveRetryConfig, retryAsync, type RetryConfig } from "../infra/retry.js"; +import { resolveFetch } from "../../../src/infra/fetch.js"; +import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../src/infra/retry.js"; const DISCORD_API_BASE = "https://discord.com/api/v10"; const DISCORD_API_RETRY_DEFAULTS = { diff --git a/src/discord/audit.test.ts b/extensions/discord/src/audit.test.ts similarity index 92% rename from src/discord/audit.test.ts rename to extensions/discord/src/audit.test.ts index 55339b03381..c1b276f320b 100644 --- a/src/discord/audit.test.ts +++ b/extensions/discord/src/audit.test.ts @@ -27,7 +27,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, @@ -73,7 +73,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); @@ -98,7 +98,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual([]); @@ -127,7 +127,7 @@ describe("discord audit", () => { }, }, }, - } as unknown as import("../config/config.js").OpenClawConfig; + } as unknown as import("../../../src/config/config.js").OpenClawConfig; const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" }); expect(collected.channelIds).toEqual(["111"]); diff --git a/src/discord/audit.ts b/extensions/discord/src/audit.ts similarity index 96% rename from src/discord/audit.ts rename to extensions/discord/src/audit.ts index d2a6477e47f..a5a226c5550 100644 --- a/src/discord/audit.ts +++ b/extensions/discord/src/audit.ts @@ -1,6 +1,6 @@ -import type { OpenClawConfig } from "../config/config.js"; -import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../config/types.js"; -import { isRecord } from "../utils.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../../../src/config/types.js"; +import { isRecord } from "../../../src/utils.js"; import { inspectDiscordAccount } from "./account-inspect.js"; import { fetchChannelPermissionsDiscord } from "./send.js"; diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts new file mode 100644 index 00000000000..bf35b788e3e --- /dev/null +++ b/extensions/discord/src/channel-actions.ts @@ -0,0 +1,140 @@ +import { + createUnionActionGate, + listTokenSourcedAccounts, +} from "../../../src/channels/plugins/actions/shared.js"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "../../../src/channels/plugins/types.js"; +import type { DiscordActionConfig } from "../../../src/config/types.discord.js"; +import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js"; +import { handleDiscordMessageAction } from "./actions/handle-action.js"; + +export const discordMessageActions: ChannelMessageActionAdapter = { + listActions: ({ cfg }) => { + const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg)); + if (accounts.length === 0) { + return []; + } + // Union of all accounts' action gates (any account enabling an action makes it available) + const gate = createUnionActionGate(accounts, (account) => + createDiscordActionGate({ + cfg, + accountId: account.accountId, + }), + ); + const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) => + gate(key, defaultValue); + const actions = new Set(["send"]); + if (isEnabled("polls")) { + actions.add("poll"); + } + if (isEnabled("reactions")) { + actions.add("react"); + actions.add("reactions"); + } + if (isEnabled("messages")) { + actions.add("read"); + actions.add("edit"); + actions.add("delete"); + } + if (isEnabled("pins")) { + actions.add("pin"); + actions.add("unpin"); + actions.add("list-pins"); + } + if (isEnabled("permissions")) { + actions.add("permissions"); + } + if (isEnabled("threads")) { + actions.add("thread-create"); + actions.add("thread-list"); + actions.add("thread-reply"); + } + if (isEnabled("search")) { + actions.add("search"); + } + if (isEnabled("stickers")) { + actions.add("sticker"); + } + if (isEnabled("memberInfo")) { + actions.add("member-info"); + } + if (isEnabled("roleInfo")) { + actions.add("role-info"); + } + if (isEnabled("reactions")) { + actions.add("emoji-list"); + } + if (isEnabled("emojiUploads")) { + actions.add("emoji-upload"); + } + if (isEnabled("stickerUploads")) { + actions.add("sticker-upload"); + } + if (isEnabled("roles", false)) { + actions.add("role-add"); + actions.add("role-remove"); + } + if (isEnabled("channelInfo")) { + actions.add("channel-info"); + actions.add("channel-list"); + } + if (isEnabled("channels")) { + actions.add("channel-create"); + actions.add("channel-edit"); + actions.add("channel-delete"); + actions.add("channel-move"); + actions.add("category-create"); + actions.add("category-edit"); + actions.add("category-delete"); + } + if (isEnabled("voiceStatus")) { + actions.add("voice-status"); + } + if (isEnabled("events")) { + actions.add("event-list"); + actions.add("event-create"); + } + if (isEnabled("moderation", false)) { + actions.add("timeout"); + actions.add("kick"); + actions.add("ban"); + } + if (isEnabled("presence", false)) { + actions.add("set-presence"); + } + return Array.from(actions); + }, + extractToolSend: ({ args }) => { + const action = typeof args.action === "string" ? args.action.trim() : ""; + if (action === "sendMessage") { + const to = typeof args.to === "string" ? args.to : undefined; + return to ? { to } : null; + } + if (action === "threadReply") { + const channelId = typeof args.channelId === "string" ? args.channelId.trim() : ""; + return channelId ? { to: `channel:${channelId}` } : null; + } + return null; + }, + handleAction: async ({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }) => { + return await handleDiscordMessageAction({ + action, + params, + cfg, + accountId, + requesterSenderId, + toolContext, + mediaLocalRoots, + }); + }, +}; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index c6852a63469..c910e56342d 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -37,8 +37,13 @@ import { type ChannelPlugin, type ResolvedDiscordAccount, } from "openclaw/plugin-sdk/discord"; +import { resolveOutboundSendDep } from "../../../src/infra/outbound/deliver.js"; import { getDiscordRuntime } from "./runtime.js"; +type DiscordSendFn = ReturnType< + typeof getDiscordRuntime +>["channel"]["discord"]["sendMessageDiscord"]; + const meta = getChatChannelMeta("discord"); const discordMessageActions: ChannelMessageActionAdapter = { @@ -300,7 +305,9 @@ export const discordPlugin: ChannelPlugin = { pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, @@ -321,7 +328,9 @@ export const discordPlugin: ChannelPlugin = { replyToId, silent, }) => { - const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord; + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; const result = await send(to, text, { verbose: false, cfg, diff --git a/src/discord/chunk.test.ts b/extensions/discord/src/chunk.test.ts similarity index 98% rename from src/discord/chunk.test.ts rename to extensions/discord/src/chunk.test.ts index d33262c4767..3c667c0fc9f 100644 --- a/src/discord/chunk.test.ts +++ b/extensions/discord/src/chunk.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { countLines, hasBalancedFences } from "../test-utils/chunk-test-helpers.js"; +import { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js"; import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js"; describe("chunkDiscordText", () => { diff --git a/src/discord/chunk.ts b/extensions/discord/src/chunk.ts similarity index 98% rename from src/discord/chunk.ts rename to extensions/discord/src/chunk.ts index 242d5c74c2d..a814c10d2c8 100644 --- a/src/discord/chunk.ts +++ b/extensions/discord/src/chunk.ts @@ -1,4 +1,4 @@ -import { chunkMarkdownTextWithMode, type ChunkMode } from "../auto-reply/chunk.js"; +import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../src/auto-reply/chunk.js"; export type ChunkDiscordTextOpts = { /** Max characters per Discord message. Default: 2000. */ diff --git a/src/discord/client.test.ts b/extensions/discord/src/client.test.ts similarity index 96% rename from src/discord/client.test.ts rename to extensions/discord/src/client.test.ts index 3dc156670e7..416fa7c903a 100644 --- a/src/discord/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../config/config.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; import { createDiscordRestClient } from "./client.js"; describe("createDiscordRestClient", () => { diff --git a/src/discord/client.ts b/extensions/discord/src/client.ts similarity index 90% rename from src/discord/client.ts rename to extensions/discord/src/client.ts index 62d917cebb6..2e8d53799a6 100644 --- a/src/discord/client.ts +++ b/extensions/discord/src/client.ts @@ -1,8 +1,8 @@ import { RequestClient } from "@buape/carbon"; -import { loadConfig } from "../config/config.js"; -import { createDiscordRetryRunner, type RetryRunner } from "../infra/retry-policy.js"; -import type { RetryConfig } from "../infra/retry.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { loadConfig } from "../../../src/config/config.js"; +import { createDiscordRetryRunner, type RetryRunner } from "../../../src/infra/retry-policy.js"; +import type { RetryConfig } from "../../../src/infra/retry.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; import { mergeDiscordAccountConfig, resolveDiscordAccount, diff --git a/src/discord/components-registry.ts b/extensions/discord/src/components-registry.ts similarity index 100% rename from src/discord/components-registry.ts rename to extensions/discord/src/components-registry.ts diff --git a/src/discord/components.test.ts b/extensions/discord/src/components.test.ts similarity index 100% rename from src/discord/components.test.ts rename to extensions/discord/src/components.test.ts diff --git a/src/discord/components.ts b/extensions/discord/src/components.ts similarity index 100% rename from src/discord/components.ts rename to extensions/discord/src/components.ts diff --git a/src/discord/directory-cache.ts b/extensions/discord/src/directory-cache.ts similarity index 97% rename from src/discord/directory-cache.ts rename to extensions/discord/src/directory-cache.ts index 4cb17865eae..d1a85767216 100644 --- a/src/discord/directory-cache.ts +++ b/extensions/discord/src/directory-cache.ts @@ -1,4 +1,4 @@ -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/account-id.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/account-id.js"; const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000; const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/; diff --git a/src/discord/directory-live.test.ts b/extensions/discord/src/directory-live.test.ts similarity index 97% rename from src/discord/directory-live.test.ts rename to extensions/discord/src/directory-live.test.ts index e6f19d448d8..8ba3bc52c4a 100644 --- a/src/discord/directory-live.test.ts +++ b/extensions/discord/src/directory-live.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; const mocks = vi.hoisted(() => ({ fetchDiscord: vi.fn(), diff --git a/src/discord/directory-live.ts b/extensions/discord/src/directory-live.ts similarity index 95% rename from src/discord/directory-live.ts rename to extensions/discord/src/directory-live.ts index d57d3e775a9..af55475a43e 100644 --- a/src/discord/directory-live.ts +++ b/extensions/discord/src/directory-live.ts @@ -1,5 +1,5 @@ -import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js"; -import type { ChannelDirectoryEntry } from "../channels/plugins/types.js"; +import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js"; +import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js"; import { resolveDiscordAccount } from "./accounts.js"; import { fetchDiscord } from "./api.js"; import { rememberDiscordDirectoryUser } from "./directory-cache.js"; diff --git a/src/discord/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts similarity index 78% rename from src/discord/draft-chunking.ts rename to extensions/discord/src/draft-chunking.ts index 76231bc8397..ce4048379d1 100644 --- a/src/discord/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -1,8 +1,8 @@ -import { resolveTextChunkLimit } from "../auto-reply/chunk.js"; -import { getChannelDock } from "../channels/dock.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { resolveAccountEntry } from "../routing/account-lookup.js"; -import { normalizeAccountId } from "../routing/session-key.js"; +import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js"; +import { getChannelDock } from "../../../src/channels/dock.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { resolveAccountEntry } from "../../../src/routing/account-lookup.js"; +import { normalizeAccountId } from "../../../src/routing/session-key.js"; const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200; const DEFAULT_DISCORD_DRAFT_STREAM_MAX = 800; diff --git a/src/discord/draft-stream.ts b/extensions/discord/src/draft-stream.ts similarity index 97% rename from src/discord/draft-stream.ts rename to extensions/discord/src/draft-stream.ts index 0281d4c0227..db9089f6176 100644 --- a/src/discord/draft-stream.ts +++ b/extensions/discord/src/draft-stream.ts @@ -1,6 +1,6 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; -import { createFinalizableDraftLifecycle } from "../channels/draft-stream-controls.js"; +import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js"; /** Discord messages cap at 2000 characters. */ const DISCORD_STREAM_MAX_CHARS = 2000; diff --git a/src/discord/exec-approvals.ts b/extensions/discord/src/exec-approvals.ts similarity index 72% rename from src/discord/exec-approvals.ts rename to extensions/discord/src/exec-approvals.ts index f4be9a22e0c..5640805705a 100644 --- a/src/discord/exec-approvals.ts +++ b/extensions/discord/src/exec-approvals.ts @@ -1,6 +1,6 @@ -import type { ReplyPayload } from "../auto-reply/types.js"; -import type { OpenClawConfig } from "../config/config.js"; -import { getExecApprovalReplyMetadata } from "../infra/exec-approval-reply.js"; +import type { ReplyPayload } from "../../../src/auto-reply/types.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js"; import { resolveDiscordAccount } from "./accounts.js"; export function isDiscordExecApprovalClientEnabled(params: { diff --git a/src/discord/gateway-logging.test.ts b/extensions/discord/src/gateway-logging.test.ts similarity index 96% rename from src/discord/gateway-logging.test.ts rename to extensions/discord/src/gateway-logging.test.ts index 762cf5d160b..e6fc4d0f714 100644 --- a/src/discord/gateway-logging.test.ts +++ b/extensions/discord/src/gateway-logging.test.ts @@ -1,11 +1,11 @@ import { EventEmitter } from "node:events"; import { afterEach, describe, expect, it, vi } from "vitest"; -vi.mock("../globals.js", () => ({ +vi.mock("../../../src/globals.js", () => ({ logVerbose: vi.fn(), })); -import { logVerbose } from "../globals.js"; +import { logVerbose } from "../../../src/globals.js"; import { attachDiscordGatewayLogging } from "./gateway-logging.js"; const makeRuntime = () => ({ diff --git a/src/discord/gateway-logging.ts b/extensions/discord/src/gateway-logging.ts similarity index 94% rename from src/discord/gateway-logging.ts rename to extensions/discord/src/gateway-logging.ts index 916952020be..18ce32909ef 100644 --- a/src/discord/gateway-logging.ts +++ b/extensions/discord/src/gateway-logging.ts @@ -1,6 +1,6 @@ import type { EventEmitter } from "node:events"; -import { logVerbose } from "../globals.js"; -import type { RuntimeEnv } from "../runtime.js"; +import { logVerbose } from "../../../src/globals.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; type GatewayEmitter = Pick; diff --git a/src/discord/guilds.ts b/extensions/discord/src/guilds.ts similarity index 100% rename from src/discord/guilds.ts rename to extensions/discord/src/guilds.ts diff --git a/src/discord/mentions.test.ts b/extensions/discord/src/mentions.test.ts similarity index 100% rename from src/discord/mentions.test.ts rename to extensions/discord/src/mentions.test.ts diff --git a/src/discord/mentions.ts b/extensions/discord/src/mentions.ts similarity index 100% rename from src/discord/mentions.ts rename to extensions/discord/src/mentions.ts diff --git a/src/discord/monitor.gateway.test.ts b/extensions/discord/src/monitor.gateway.test.ts similarity index 100% rename from src/discord/monitor.gateway.test.ts rename to extensions/discord/src/monitor.gateway.test.ts diff --git a/src/discord/monitor.gateway.ts b/extensions/discord/src/monitor.gateway.ts similarity index 100% rename from src/discord/monitor.gateway.ts rename to extensions/discord/src/monitor.gateway.ts diff --git a/src/discord/monitor.test.ts b/extensions/discord/src/monitor.test.ts similarity index 97% rename from src/discord/monitor.test.ts rename to extensions/discord/src/monitor.test.ts index 9471a3fe6bc..40f14a00551 100644 --- a/src/discord/monitor.test.ts +++ b/extensions/discord/src/monitor.test.ts @@ -1,6 +1,6 @@ import { ChannelType, type Guild } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { typedCases } from "../test-utils/typed-cases.js"; +import { typedCases } from "../../../src/test-utils/typed-cases.js"; import { allowListMatches, buildDiscordMediaPayload, @@ -22,7 +22,7 @@ import { DiscordMessageListener, DiscordReactionListener } from "./monitor/liste const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); -vi.mock("../pairing/pairing-store.js", () => ({ +vi.mock("../../../src/pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), })); @@ -157,7 +157,9 @@ describe("DiscordMessageListener", () => { const logger = { warn: vi.fn(), error: vi.fn(), - } as unknown as ReturnType; + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >; const handler = vi.fn(async () => { throw new Error("boom"); }); @@ -178,7 +180,9 @@ describe("DiscordMessageListener", () => { const logger = { warn: vi.fn(), error: vi.fn(), - } as unknown as ReturnType; + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >; const listener = new DiscordMessageListener(handler, logger); const handlePromise = listener.handle( @@ -247,6 +251,18 @@ describe("discord guild/channel resolution", () => { expect(resolved?.slug).toBe("friends-of-openclaw"); }); + it("resolves guild entry by raw guild id when guild object is missing", () => { + const guildEntries = makeEntries({ + "123": { slug: "friends-of-openclaw" }, + }); + const resolved = resolveDiscordGuildEntry({ + guildId: "123", + guildEntries, + }); + expect(resolved?.id).toBe("123"); + expect(resolved?.slug).toBe("friends-of-openclaw"); + }); + it("resolves guild entry by slug key", () => { const guildEntries = makeEntries({ "friends-of-openclaw": { slug: "friends-of-openclaw" }, @@ -876,11 +892,11 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({ })), })); -vi.mock("../infra/system-events.js", () => ({ +vi.mock("../../../src/infra/system-events.js", () => ({ enqueueSystemEvent: enqueueSystemEventSpy, })); -vi.mock("../routing/resolve-route.js", () => ({ +vi.mock("../../../src/routing/resolve-route.js", () => ({ resolveAgentRoute: resolveAgentRouteMock, })); @@ -961,9 +977,9 @@ function makeReactionListenerParams(overrides?: { guildEntries?: Record; }) { return { - cfg: {} as ReturnType, + cfg: {} as ReturnType, accountId: "acc-1", - runtime: {} as import("../runtime.js").RuntimeEnv, + runtime: {} as import("../../../src/runtime.js").RuntimeEnv, botUserId: overrides?.botUserId ?? "bot-1", dmEnabled: overrides?.dmEnabled ?? true, groupDmEnabled: overrides?.groupDmEnabled ?? true, @@ -978,7 +994,9 @@ function makeReactionListenerParams(overrides?: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - } as unknown as ReturnType, + } as unknown as ReturnType< + typeof import("../../../src/logging/subsystem.js").createSubsystemLogger + >, }; } diff --git a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts similarity index 96% rename from src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts rename to extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts index b85ec0c060d..6461fcef756 100644 --- a/src/discord/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts +++ b/extensions/discord/src/monitor.tool-result.accepts-guild-messages-mentionpatterns-match.e2e.test.ts @@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js"; +import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js"; import { dispatchMock, readAllowFromStoreMock, @@ -14,8 +14,8 @@ import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.j import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; const loadConfigMock = vi.fn(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/config.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadConfig: (...args: unknown[]) => loadConfigMock(...args), @@ -63,7 +63,7 @@ beforeEach(() => { const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000; -type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>; +type LoadedConfig = ReturnType<(typeof import("../../../src/config/config.js"))["loadConfig"]>; let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler; let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand; @@ -322,7 +322,7 @@ describe("discord tool result dispatch", () => { channels: { discord: { dm: { enabled: true, policy: "open" } }, }, - } as ReturnType; + } as ReturnType; const command = createDiscordNativeCommand({ command: { @@ -451,7 +451,7 @@ describe("discord tool result dispatch", () => { const cfg = { ...createDefaultThreadConfig(), routing: { allowFrom: [] }, - } as ReturnType; + } as ReturnType; const handler = await createHandler(cfg); diff --git a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts similarity index 98% rename from src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts rename to extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts index 70d7fd53708..d1340f49852 100644 --- a/src/discord/monitor.tool-result.sends-status-replies-responseprefix.test.ts +++ b/extensions/discord/src/monitor.tool-result.sends-status-replies-responseprefix.test.ts @@ -12,7 +12,7 @@ import { createDiscordMessageHandler } from "./monitor/message-handler.js"; import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js"; import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js"; -type Config = ReturnType; +type Config = ReturnType; beforeEach(() => { __resetDiscordChannelInfoCacheForTest(); diff --git a/src/discord/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts similarity index 72% rename from src/discord/monitor.tool-result.test-harness.ts rename to extensions/discord/src/monitor.tool-result.test-harness.ts index 0d4596b3281..700e9a63df3 100644 --- a/src/discord/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -1,5 +1,5 @@ import { vi } from "vitest"; -import type { MockFn } from "../test-utils/vitest-mock-fn.js"; +import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js"; export const sendMock: MockFn = vi.fn(); export const reactMock: MockFn = vi.fn(); @@ -15,8 +15,8 @@ vi.mock("./send.js", () => ({ }, })); -vi.mock("../auto-reply/dispatch.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/auto-reply/dispatch.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args), @@ -36,10 +36,10 @@ function createPairingStoreMocks() { }; } -vi.mock("../pairing/pairing-store.js", () => createPairingStoreMocks()); +vi.mock("../../../src/pairing/pairing-store.js", () => createPairingStoreMocks()); -vi.mock("../config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("../../../src/config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), diff --git a/src/discord/monitor.ts b/extensions/discord/src/monitor.ts similarity index 100% rename from src/discord/monitor.ts rename to extensions/discord/src/monitor.ts diff --git a/src/discord/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts similarity index 96% rename from src/discord/monitor/agent-components.ts rename to extensions/discord/src/monitor/agent-components.ts index 56e7dfe3240..e954c372bb1 100644 --- a/src/discord/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -17,32 +17,35 @@ import { } from "@buape/carbon"; import type { APIStringSelectComponent } from "discord-api-types/v10"; import { ButtonStyle, ChannelType } from "discord-api-types/v10"; -import { resolveHumanDelayConfig } from "../../agents/identity.js"; -import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; -import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; -import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; -import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; -import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; -import { recordInboundSession } from "../../channels/session.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js"; -import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; -import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js"; -import type { DiscordAccountConfig } from "../../config/types.discord.js"; -import { logVerbose } from "../../globals.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { logDebug, logError } from "../../logger.js"; -import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js"; +import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js"; +import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; +import { + formatInboundEnvelope, + resolveEnvelopeFormatOptions, +} from "../../../../src/auto-reply/envelope.js"; +import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js"; +import { dispatchReplyWithBufferedBlockDispatcher } from "../../../../src/auto-reply/reply/provider-dispatcher.js"; +import { createReplyReferencePlanner } from "../../../../src/auto-reply/reply/reply-reference.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; +import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js"; +import { recordInboundSession } from "../../../../src/channels/session.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js"; +import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js"; +import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js"; +import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js"; +import { logVerbose } from "../../../../src/globals.js"; +import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; +import { logDebug, logError } from "../../../../src/logger.js"; +import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; +import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js"; +import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { @@ -360,6 +363,7 @@ async function ensureAgentComponentInteractionAllowed(params: { }): Promise<{ parentId: string | undefined } | null> { const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: params.rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1094,6 +1098,7 @@ async function handleDiscordComponentEvent(params: { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1246,6 +1251,7 @@ async function handleDiscordModalTrigger(params: { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); @@ -1696,6 +1702,7 @@ class DiscordComponentModal extends Modal { const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx; const guildInfo = resolveDiscordGuildEntry({ guild: interaction.guild ?? undefined, + guildId: rawGuildId, guildEntries: this.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(interaction); diff --git a/src/discord/monitor/agent-components.wildcard.test.ts b/extensions/discord/src/monitor/agent-components.wildcard.test.ts similarity index 100% rename from src/discord/monitor/agent-components.wildcard.test.ts rename to extensions/discord/src/monitor/agent-components.wildcard.test.ts diff --git a/src/discord/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts similarity index 91% rename from src/discord/monitor/allow-list.ts rename to extensions/discord/src/monitor/allow-list.ts index 7c1250cb8ef..6391ad5c3a5 100644 --- a/src/discord/monitor/allow-list.ts +++ b/extensions/discord/src/monitor/allow-list.ts @@ -1,12 +1,12 @@ import type { Guild, User } from "@buape/carbon"; -import type { AllowlistMatch } from "../../channels/allowlist-match.js"; +import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js"; import { buildChannelKeyCandidates, resolveChannelEntryMatchWithFallback, resolveChannelMatchConfig, type ChannelMatchSource, -} from "../../channels/channel-config.js"; -import { evaluateGroupRouteAccessForPolicy } from "../../plugin-sdk/group-access.js"; +} from "../../../../src/channels/channel-config.js"; +import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js"; import { formatDiscordUserTag } from "./format.js"; export type DiscordAllowList = { @@ -19,34 +19,7 @@ export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"]; -export type DiscordGuildEntryResolved = { - id?: string; - slug?: string; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - reactionNotifications?: "off" | "own" | "all" | "allowlist"; - users?: string[]; - roles?: string[]; - channels?: Record< - string, - { - allow?: boolean; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - skills?: string[]; - enabled?: boolean; - users?: string[]; - roles?: string[]; - systemPrompt?: string; - includeThreadStarter?: boolean; - autoThread?: boolean; - autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; - } - >; -}; - -export type DiscordChannelConfigResolved = { - allowed: boolean; +type DiscordChannelOverrideConfig = { requireMention?: boolean; ignoreOtherMentions?: boolean; skills?: string[]; @@ -57,6 +30,21 @@ export type DiscordChannelConfigResolved = { includeThreadStarter?: boolean; autoThread?: boolean; autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; +}; + +export type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + ignoreOtherMentions?: boolean; + reactionNotifications?: "off" | "own" | "all" | "allowlist"; + users?: string[]; + roles?: string[]; + channels?: Record; +}; + +export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & { + allowed: boolean; matchKey?: string; matchSource?: ChannelMatchSource; }; @@ -103,6 +91,21 @@ export function normalizeDiscordSlug(value: string) { .replace(/^-+|-+$/g, ""); } +function resolveDiscordAllowListNameMatch( + list: DiscordAllowList, + candidate: { name?: string; tag?: string }, +): { matchKey: string; matchSource: "name" | "tag" } | null { + const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; + if (nameSlug && list.names.has(nameSlug)) { + return { matchKey: nameSlug, matchSource: "name" }; + } + const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : ""; + if (tagSlug && list.names.has(tagSlug)) { + return { matchKey: tagSlug, matchSource: "tag" }; + } + return null; +} + export function allowListMatches( list: DiscordAllowList, candidate: { id?: string; name?: string; tag?: string }, @@ -115,11 +118,7 @@ export function allowListMatches( return true; } if (params?.allowNameMatching === true) { - const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; - if (slug && list.names.has(slug)) { - return true; - } - if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag))) { + if (resolveDiscordAllowListNameMatch(list, candidate)) { return true; } } @@ -139,13 +138,9 @@ export function resolveDiscordAllowListMatch(params: { return { allowed: true, matchKey: candidate.id, matchSource: "id" }; } if (params.allowNameMatching === true) { - const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : ""; - if (nameSlug && allowList.names.has(nameSlug)) { - return { allowed: true, matchKey: nameSlug, matchSource: "name" }; - } - const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : ""; - if (tagSlug && allowList.names.has(tagSlug)) { - return { allowed: true, matchKey: tagSlug, matchSource: "tag" }; + const namedMatch = resolveDiscordAllowListNameMatch(allowList, candidate); + if (namedMatch) { + return { allowed: true, ...namedMatch }; } } return { allowed: false }; @@ -326,25 +321,30 @@ export function resolveDiscordCommandAuthorized(params: { export function resolveDiscordGuildEntry(params: { guild?: Guild | Guild | null; + guildId?: string | null; guildEntries?: Record; }): DiscordGuildEntryResolved | null { const guild = params.guild; const entries = params.guildEntries; - if (!guild || !entries) { + const guildId = params.guildId?.trim() || guild?.id; + if (!entries) { return null; } - const byId = entries[guild.id]; + const byId = guildId ? entries[guildId] : undefined; if (byId) { - return { ...byId, id: guild.id }; + return { ...byId, id: guildId }; + } + if (!guild) { + return null; } const slug = normalizeDiscordSlug(guild.name ?? ""); const bySlug = entries[slug]; if (bySlug) { - return { ...bySlug, id: guild.id, slug: slug || bySlug.slug }; + return { ...bySlug, id: guildId ?? guild.id, slug: slug || bySlug.slug }; } const wildcard = entries["*"]; if (wildcard) { - return { ...wildcard, id: guild.id, slug: slug || wildcard.slug }; + return { ...wildcard, id: guildId ?? guild.id, slug: slug || wildcard.slug }; } return null; } diff --git a/src/discord/monitor/auto-presence.test.ts b/extensions/discord/src/monitor/auto-presence.test.ts similarity index 73% rename from src/discord/monitor/auto-presence.test.ts rename to extensions/discord/src/monitor/auto-presence.test.ts index b5a83d5242d..3e81b523bc9 100644 --- a/src/discord/monitor/auto-presence.test.ts +++ b/extensions/discord/src/monitor/auto-presence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import type { AuthProfileStore } from "../../../../src/agents/auth-profiles.js"; import { createDiscordAutoPresenceController, resolveDiscordAutoPresenceDecision, @@ -29,45 +29,33 @@ function createStore(params?: { }; } +function expectExhaustedDecision(params: { failureCounts: Record }) { + const now = Date.now(); + const decision = resolveDiscordAutoPresenceDecision({ + discordConfig: { + autoPresence: { + enabled: true, + exhaustedText: "token exhausted", + }, + }, + authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: params.failureCounts }), + gatewayConnected: true, + now, + }); + + expect(decision).toBeTruthy(); + expect(decision?.state).toBe("exhausted"); + expect(decision?.presence.status).toBe("dnd"); + expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); +} + describe("discord auto presence", () => { it("maps exhausted runtime signal to dnd", () => { - const now = Date.now(); - const decision = resolveDiscordAutoPresenceDecision({ - discordConfig: { - autoPresence: { - enabled: true, - exhaustedText: "token exhausted", - }, - }, - authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { rate_limit: 2 } }), - gatewayConnected: true, - now, - }); - - expect(decision).toBeTruthy(); - expect(decision?.state).toBe("exhausted"); - expect(decision?.presence.status).toBe("dnd"); - expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); + expectExhaustedDecision({ failureCounts: { rate_limit: 2 } }); }); it("treats overloaded cooldown as exhausted", () => { - const now = Date.now(); - const decision = resolveDiscordAutoPresenceDecision({ - discordConfig: { - autoPresence: { - enabled: true, - exhaustedText: "token exhausted", - }, - }, - authStore: createStore({ cooldownUntil: now + 60_000, failureCounts: { overloaded: 2 } }), - gatewayConnected: true, - now, - }); - - expect(decision).toBeTruthy(); - expect(decision?.state).toBe("exhausted"); - expect(decision?.presence.status).toBe("dnd"); - expect(decision?.presence.activities[0]?.state).toBe("token exhausted"); + expectExhaustedDecision({ failureCounts: { overloaded: 2 } }); }); it("recovers from exhausted to online once a profile becomes usable", () => { diff --git a/src/discord/monitor/auto-presence.ts b/extensions/discord/src/monitor/auto-presence.ts similarity index 97% rename from src/discord/monitor/auto-presence.ts rename to extensions/discord/src/monitor/auto-presence.ts index 8c139382dc6..60e5619e348 100644 --- a/src/discord/monitor/auto-presence.ts +++ b/extensions/discord/src/monitor/auto-presence.ts @@ -6,9 +6,12 @@ import { resolveProfilesUnavailableReason, type AuthProfileFailureReason, type AuthProfileStore, -} from "../../agents/auth-profiles.js"; -import type { DiscordAccountConfig, DiscordAutoPresenceConfig } from "../../config/config.js"; -import { warn } from "../../globals.js"; +} from "../../../../src/agents/auth-profiles.js"; +import type { + DiscordAccountConfig, + DiscordAutoPresenceConfig, +} from "../../../../src/config/config.js"; +import { warn } from "../../../../src/globals.js"; import { resolveDiscordPresenceUpdate } from "./presence.js"; const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4; diff --git a/src/discord/monitor/commands.test.ts b/extensions/discord/src/monitor/commands.test.ts similarity index 100% rename from src/discord/monitor/commands.test.ts rename to extensions/discord/src/monitor/commands.test.ts diff --git a/src/discord/monitor/commands.ts b/extensions/discord/src/monitor/commands.ts similarity index 67% rename from src/discord/monitor/commands.ts rename to extensions/discord/src/monitor/commands.ts index 96a277785df..a9bb9c1548e 100644 --- a/src/discord/monitor/commands.ts +++ b/extensions/discord/src/monitor/commands.ts @@ -1,4 +1,4 @@ -import type { DiscordSlashCommandConfig } from "../../config/types.discord.js"; +import type { DiscordSlashCommandConfig } from "../../../../src/config/types.discord.js"; export function resolveDiscordSlashCommandConfig( raw?: DiscordSlashCommandConfig, diff --git a/src/discord/monitor/dm-command-auth.test.ts b/extensions/discord/src/monitor/dm-command-auth.test.ts similarity index 100% rename from src/discord/monitor/dm-command-auth.test.ts rename to extensions/discord/src/monitor/dm-command-auth.test.ts diff --git a/src/discord/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts similarity index 95% rename from src/discord/monitor/dm-command-auth.ts rename to extensions/discord/src/monitor/dm-command-auth.ts index 2a9e18be0b0..2fa02d9d605 100644 --- a/src/discord/monitor/dm-command-auth.ts +++ b/extensions/discord/src/monitor/dm-command-auth.ts @@ -1,9 +1,9 @@ -import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; +import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js"; import { readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithLists, type DmGroupAccessDecision, -} from "../../security/dm-policy-shared.js"; +} from "../../../../src/security/dm-policy-shared.js"; import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js"; const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"]; diff --git a/src/discord/monitor/dm-command-decision.test.ts b/extensions/discord/src/monitor/dm-command-decision.test.ts similarity index 100% rename from src/discord/monitor/dm-command-decision.test.ts rename to extensions/discord/src/monitor/dm-command-decision.test.ts diff --git a/src/discord/monitor/dm-command-decision.ts b/extensions/discord/src/monitor/dm-command-decision.ts similarity index 88% rename from src/discord/monitor/dm-command-decision.ts rename to extensions/discord/src/monitor/dm-command-decision.ts index d5b533bfdaa..8c15e7cac11 100644 --- a/src/discord/monitor/dm-command-decision.ts +++ b/extensions/discord/src/monitor/dm-command-decision.ts @@ -1,5 +1,5 @@ -import { issuePairingChallenge } from "../../pairing/pairing-challenge.js"; -import { upsertChannelPairingRequest } from "../../pairing/pairing-store.js"; +import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js"; +import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js"; import type { DiscordDmCommandAccess } from "./dm-command-auth.js"; export async function handleDiscordDmCommandDecision(params: { diff --git a/src/discord/monitor/exec-approvals.test.ts b/extensions/discord/src/monitor/exec-approvals.test.ts similarity index 92% rename from src/discord/monitor/exec-approvals.test.ts rename to extensions/discord/src/monitor/exec-approvals.test.ts index 8f9430393a2..be3ead1d400 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/extensions/discord/src/monitor/exec-approvals.test.ts @@ -4,8 +4,8 @@ import path from "node:path"; import type { ButtonInteraction, ComponentData } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { clearSessionStoreCacheForTest } from "../../config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; +import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; import { buildExecApprovalCustomId, extractDiscordChannelId, @@ -76,7 +76,7 @@ vi.mock("../send.shared.js", async (importOriginal) => { }; }); -vi.mock("../../gateway/client.js", () => ({ +vi.mock("../../../../src/gateway/client.js", () => ({ GatewayClient: class { private params: Record; constructor(params: Record) { @@ -96,11 +96,11 @@ vi.mock("../../gateway/client.js", () => ({ }, })); -vi.mock("../../gateway/connection-auth.js", () => ({ +vi.mock("../../../../src/gateway/connection-auth.js", () => ({ resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth, })); -vi.mock("../../logger.js", () => ({ +vi.mock("../../../../src/logger.js", () => ({ logDebug: vi.fn(), logError: vi.fn(), })); @@ -116,6 +116,62 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default") }); } +function mockSuccessfulDmDelivery(params?: { + noteChannelId?: string; + expectedNoteText?: string; + throwOnUnexpectedRoute?: boolean; +}) { + mockRestPost.mockImplementation( + async (route: string, requestParams?: { body?: { content?: string } }) => { + if (params?.noteChannelId && route === Routes.channelMessages(params.noteChannelId)) { + if (params.expectedNoteText) { + expect(requestParams?.body?.content).toContain(params.expectedNoteText); + } + return { id: "note-1", channel_id: params.noteChannelId }; + } + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + if (params?.throwOnUnexpectedRoute) { + throw new Error(`unexpected route: ${route}`); + } + return { id: "msg-unknown" }; + }, + ); +} + +async function expectGatewayAuthStart(params: { + handler: DiscordExecApprovalHandler; + expectedUrl: string; + expectedSource: "cli" | "env"; + expectedToken?: string; + expectedPassword?: string; +}) { + await params.handler.start(); + + expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: params.expectedUrl, + urlOverrideSource: params.expectedSource, + }), + ); + + const expectedClientParams: Record = { + url: params.expectedUrl, + }; + if (params.expectedToken !== undefined) { + expectedClientParams.token = params.expectedToken; + } + if (params.expectedPassword !== undefined) { + expectedClientParams.password = params.expectedPassword; + } + expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams)); +} + type ExecApprovalHandlerInternals = { pending: Map< string, @@ -772,15 +828,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - return { id: "msg-unknown" }; - }); + mockSuccessfulDmDelivery(); const request = createRequest({ sessionKey: "agent:main:discord:dm:123" }); await internals.handleApprovalRequested(request); @@ -809,21 +857,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation( - async (route: string, params?: { body?: { content?: string } }) => { - if (route === Routes.channelMessages("999888777")) { - expect(params?.body?.content).toContain("I sent the allowed approvers DMs"); - return { id: "note-1", channel_id: "999888777" }; - } - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }, - ); + mockSuccessfulDmDelivery({ + noteChannelId: "999888777", + expectedNoteText: "I sent the allowed approvers DMs", + throwOnUnexpectedRoute: true, + }); await internals.handleApprovalRequested(createRequest()); @@ -853,15 +891,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }); + mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); await internals.handleApprovalRequested( createRequest({ sessionKey: "agent:main:discord:dm:123" }), @@ -890,22 +920,13 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://override.example/ws", - urlOverrideSource: "cli", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://override.example/ws", - token: "resolved-token", - password: "resolved-password", // pragma: allowlist secret - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://override.example/ws", + expectedSource: "cli", + expectedToken: "resolved-token", + expectedPassword: "resolved-password", // pragma: allowlist secret + }); await handler.stop(); }); @@ -921,20 +942,11 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://gateway-from-env.example/ws", - urlOverrideSource: "env", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://gateway-from-env.example/ws", - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://gateway-from-env.example/ws", + expectedSource: "env", + }); await handler.stop(); } finally { diff --git a/src/discord/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts similarity index 92% rename from src/discord/monitor/exec-approvals.ts rename to extensions/discord/src/monitor/exec-approvals.ts index 87dc0c9a07d..e5fda7682a9 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/extensions/discord/src/monitor/exec-approvals.ts @@ -10,24 +10,30 @@ import { type TopLevelComponents, } from "@buape/carbon"; import { ButtonStyle, Routes } from "discord-api-types/v10"; -import type { OpenClawConfig } from "../../config/config.js"; -import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; -import type { DiscordExecApprovalConfig } from "../../config/types.discord.js"; -import { GatewayClient } from "../../gateway/client.js"; -import { createOperatorApprovalsGatewayClient } from "../../gateway/operator-approvals-client.js"; -import type { EventFrame } from "../../gateway/protocol/index.js"; -import { resolveExecApprovalCommandDisplay } from "../../infra/exec-approval-command-display.js"; -import { getExecApprovalApproverDmNoticeText } from "../../infra/exec-approval-reply.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js"; +import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js"; +import { GatewayClient } from "../../../../src/gateway/client.js"; +import { createOperatorApprovalsGatewayClient } from "../../../../src/gateway/operator-approvals-client.js"; +import type { EventFrame } from "../../../../src/gateway/protocol/index.js"; +import { resolveExecApprovalCommandDisplay } from "../../../../src/infra/exec-approval-command-display.js"; +import { getExecApprovalApproverDmNoticeText } from "../../../../src/infra/exec-approval-reply.js"; import type { ExecApprovalDecision, ExecApprovalRequest, ExecApprovalResolved, -} from "../../infra/exec-approvals.js"; -import { logDebug, logError } from "../../logger.js"; -import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; -import type { RuntimeEnv } from "../../runtime.js"; -import { compileSafeRegex, testRegexWithBoundedInput } from "../../security/safe-regex.js"; -import { normalizeMessageChannel } from "../../utils/message-channel.js"; +} from "../../../../src/infra/exec-approvals.js"; +import { logDebug, logError } from "../../../../src/logger.js"; +import { + normalizeAccountId, + resolveAgentIdFromSessionKey, +} from "../../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../../src/runtime.js"; +import { + compileSafeRegex, + testRegexWithBoundedInput, +} from "../../../../src/security/safe-regex.js"; +import { normalizeMessageChannel } from "../../../../src/utils/message-channel.js"; import { createDiscordClient, stripUndefinedFields } from "../send.shared.js"; import { DiscordUiContainer } from "../ui.js"; @@ -252,17 +258,30 @@ function formatOptionalCommandPreview( return formatCommandPreview(commandText, maxChars); } +function resolveExecApprovalPreviews( + request: ExecApprovalRequest["request"], + maxChars: number, + secondaryMaxChars: number, +): { commandPreview: string; commandSecondaryPreview: string | null } { + const { commandText, commandPreview: secondaryPreview } = + resolveExecApprovalCommandDisplay(request); + return { + commandPreview: formatCommandPreview(commandText, maxChars), + commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars), + }; +} + function createExecApprovalRequestContainer(params: { request: ExecApprovalRequest; cfg: OpenClawConfig; accountId: string; actionRow?: Row