Compare commits

...

51 Commits

Author SHA1 Message Date
quotentiroler
f1041cf773 auto create develop branch too 2026-02-07 09:05:23 -08:00
quotentiroler
ae5afafd75 CI: harden heredoc delimiters, add maintainer, fix formatting [skip ci] 2026-02-07 09:05:23 -08:00
quotentiroler
51ecca3828 CI: fix Greptile review findings (argjson, tag pattern, skip_tests guard) [skip ci] 2026-02-07 09:05:23 -08:00
quotentiroler
6f2f7894c9 docs: add core vs ClawHub policy and branch strategy to CONTRIBUTING.md [skip ci] 2026-02-07 09:05:23 -08:00
quotentiroler
b4b7712773 Update 2026-02-07 09:05:23 -08:00
quotentiroler
8c811194e3 CI: fix permissions, replace always() with !cancelled(), remove dead input [skip ci] 2026-02-07 09:05:23 -08:00
quotentiroler
d3518eaf9f CI: auto-create target branch if missing [skip ci] 2026-02-07 09:05:23 -08:00
quotentiroler
716f6ae376 CI: remove push trigger to prevent double-runs [skip ci] 2026-02-07 09:05:23 -08:00
quotentiroler
e200182043 CI: skip promotion for docs-only changes (paths-ignore) 2026-02-07 09:05:23 -08:00
quotentiroler
3e8c8916cc CI: fix main double-run, remove orphaned outputs, simplify release-orch ternary 2026-02-07 09:05:23 -08:00
quotentiroler
b43af44dc6 ci: progressive test gates lighter PRs, heavier staging 2026-02-07 09:05:23 -08:00
quotentiroler
b351ae1d8c ci: add concurrency controls and rollback workflow 2026-02-07 09:03:43 -08:00
quotentiroler
e8e55c24ef ci: fix workflow review findings (double-run, heredoc, permissions, notify) 2026-02-07 09:03:25 -08:00
quotentiroler
845eafaacb fix(ci): fix yaml formatting in workflow heredocs 2026-02-07 09:02:48 -08:00
quotentiroler
4f07791455 fix(ci): address Greptile review findings
- Remove undefined should_bump_version input from release.yml
- Fix Discord action variable expansion (single to double quotes)
- Add has_changes gate to changelog and test jobs
- Use unique heredoc delimiter to prevent collision with commit messages
2026-02-07 09:02:48 -08:00
quotentiroler
45789653c0 feat(ci): add hotfix workflow and pipeline documentation 2026-02-07 09:02:48 -08:00
quotentiroler
0003cd969e feat(ci): add auto-PR workflow for dev/* branches to develop 2026-02-07 09:02:48 -08:00
quotentiroler
6035bbcd2c feat(ci): implement staged branch promotion workflow
- Add testing-strategy.yml that calls existing ci.yml + adds macOS/smoke for stable
- Add promote-branch.yml for develop  alpha  beta  main promotion PRs
- Add deployment-strategy.yml for npm (alpha/beta/latest) + Docker (GHCR)
- Add release-orchestrator.yml to coordinate version  changelog  test  deploy
- Add version-operations.yml for YYYY.M.D versioning with prerelease suffixes
- Add generate-changelog.yml for conventional commit parsing
- Add release.yml manual trigger workflow
- Add discord-notify composite action for notifications
- Modify ci.yml to support workflow_call for reuse by testing-strategy
2026-02-07 09:02:48 -08:00
大猫子
a4d5c7f673 docs: add missing HEARTBEAT.md and MEMORY.md to bootstrap files list (#8105)
* docs: add missing HEARTBEAT.md and MEMORY.md to bootstrap files list

Fixes #7928

The documentation for skipBootstrap and workspace setup was missing
HEARTBEAT.md and MEMORY.md from the bootstrap files list.

Changes:
- docs/gateway/configuration.md: Add HEARTBEAT.md and MEMORY.md
- docs/zh-CN/gateway/configuration.md: Same for Chinese version
- docs/start/openclaw.md: Add HEARTBEAT.md, clarify MEMORY.md is optional
- docs/zh-CN/start/openclaw.md: Same for Chinese version

* fix: reference PR number instead of issue in CHANGELOG

* docs(workspace): align bootstrap file docs with runtime (#8105)

---------

Co-authored-by: damaozi <1811866786@qq.com>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-07 10:51:44 -05:00
Seb Slight
9a3f62cb86 docs: add symptom-first troubleshooting hub and deep runbooks (#11196)
* docs(troubleshooting): add symptom-first troubleshooting runbooks

* docs(troubleshooting): fix approvals command examples

* docs(troubleshooting): wrap symptom cases in accordions

* docs(automation): clarify userTimezone missing-key behavior

* docs(troubleshooting): fix first-60-seconds ladder order
2026-02-07 10:28:19 -05:00
Tyler Yust
1007d71f0c fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)

* fix(security): add timeout to webhook body reading (#6762)

Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.

* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)

* fix: prevent plugins from auto-enabling without user consent (#3961)

Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.

* fix: apply hierarchical mediaMaxMb config to all channels (#8749)

Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.

* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)

Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.

* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)

Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.

* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)

Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.

* fix: send stop-typing signal when run ends with NO_REPLY (#8785)

Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.

* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)

Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
   Multiple agents sharing the same workspace would produce duplicate commands
   with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
   Commands from deleted skills now get cleaned up on restart.

* fix: add size limits to unbounded in-memory caches (#4948)

Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account

* fix: address review concerns (#11093)

- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
Peter Steinberger
9f703a44dc chore(release): 2026.2.6-3 2026-02-07 00:44:32 -08:00
Peter Steinberger
85ed6c7fa4 chore(onboard): reorder xAI + Qianfan providers 2026-02-07 00:43:13 -08:00
Peter Steinberger
ad4dd0422e chore(release): 2026.2.6-2 2026-02-07 00:30:43 -08:00
Peter Steinberger
4ba9809f18 test(hooks): stabilize session-memory hook tests 2026-02-07 00:22:34 -08:00
Peter Steinberger
80d42eb0ba fix(docker): support .mjs entrypoints in images and e2e 2026-02-07 00:22:34 -08:00
Peter Steinberger
2b6cf03b47 fix(build): support daemon-cli .mjs bundles in compat shim 2026-02-07 00:22:34 -08:00
Peter Steinberger
88ffad1c4f Merge PR #8868: add Baidu Qianfan support (thanks @ide-rea) 2026-02-07 00:19:04 -08:00
Peter Steinberger
875324e7c7 docs(changelog): note CI pipeline optimization (#10784) (thanks @mcaxtr) 2026-02-06 23:31:48 -08:00
Marcus Castro
2d7428a7f2 ci: re-enable parallel vitest on Windows CI 2026-02-06 23:31:48 -08:00
Marcus Castro
47596257ea ci: add concurrency controls, consolidate macOS jobs, optimize Windows CI 2026-02-06 23:31:48 -08:00
Peter Steinberger
ab3045cb48 chore(onboard): move xAI below Google 2026-02-06 23:06:55 -08:00
ide-rea
43c0a7fe1c Merge branch 'openclaw:main' into qianfan 2026-02-07 14:07:52 +08:00
ideoutrea
0b51f0d762 Fix conflicts 2026-02-06 18:16:20 +08:00
ideoutrea
7a9deb2400 Resolve conflicts 2026-02-06 18:13:42 +08:00
ide-rea
3997316fb0 Merge branch 'main' into qianfan 2026-02-06 17:58:28 +08:00
ideoutrea
360851366f Support ERNIE-5.0-Thinking-Preview 2026-02-06 17:49:54 +08:00
ideoutrea
7af00f040a Optimize import 2026-02-05 14:53:38 +08:00
ideoutrea
4d30f97407 Fix key resolve 2026-02-05 14:40:56 +08:00
ideoutrea
ff948a6dd7 Optimize doc 2026-02-05 14:04:23 +08:00
ideoutrea
ad759c9446 Optimize format 2026-02-05 13:50:09 +08:00
ide-rea
9ccbd57016 Merge branch 'openclaw:main' into qianfan 2026-02-05 13:36:44 +08:00
ideoutrea
52c9d3480f Add auth choice 2026-02-05 13:35:35 +08:00
ide-rea
517a8eafe5 Merge branch 'openclaw:main' into qianfan 2026-02-05 12:43:21 +08:00
ideoutrea
c8e67ad5d5 Fix import error 2026-02-05 12:39:38 +08:00
ideoutrea
fb5280e1b5 optimize doc 2026-02-04 22:57:34 +08:00
ide-rea
009abd306a Merge branch 'main' into qianfan 2026-02-04 22:39:13 +08:00
ideoutrea
8c53dfb74f Optimize doc 2026-02-04 22:30:42 +08:00
ideoutrea
7bf4080608 Fix format 2026-02-04 22:27:51 +08:00
ideoutrea
1de05ad068 Add baidu qianfan model provider 2026-02-04 22:27:49 +08:00
ideoutrea
30ac80b96b Add baidu qianfan model provider 2026-02-04 16:36:37 +08:00
123 changed files with 4111 additions and 1035 deletions

View File

@@ -0,0 +1,69 @@
name: Discord Notify
description: Send notifications to Discord webhook
inputs:
webhook_url:
description: Discord webhook URL
required: true
title:
description: Notification title
required: true
description:
description: Notification description
required: true
color:
description: Embed color (decimal)
required: false
default: "3447003"
username:
description: Bot username
required: false
default: "OpenClaw CI"
avatar_url:
description: Bot avatar URL
required: false
default: "https://avatars.githubusercontent.com/u/182880377"
timestamp:
description: Include timestamp
required: false
default: "true"
fields:
description: JSON array of embed fields
required: false
default: "[]"
runs:
using: composite
steps:
- name: Send Discord notification
shell: bash
run: |
TIMESTAMP=""
if [ "${{ inputs.timestamp }}" = "true" ]; then
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
fi
# Build JSON payload with jq to handle escaping properly
PAYLOAD=$(jq -n \
--arg username "${{ inputs.username }}" \
--arg avatar_url "${{ inputs.avatar_url }}" \
--arg title "${{ inputs.title }}" \
--arg description "${{ inputs.description }}" \
--argjson color "${{ inputs.color }}" \
--argjson fields '${{ inputs.fields }}' \
--arg timestamp "$TIMESTAMP" \
--argjson add_timestamp "${{ inputs.timestamp == 'true' }}" \
'{
username: $username,
avatar_url: $avatar_url,
embeds: [{
title: $title,
description: $description,
color: $color,
fields: $fields
} + (if $add_timestamp then {timestamp: $timestamp} else {} end)]
}')
curl -sS -H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${{ inputs.webhook_url }}"

View File

@@ -1,8 +1,19 @@
name: CI
on:
push:
pull_request:
workflow_call:
# Called by testing-strategy.yml for staged releases
inputs:
test_stage:
description: "Testing stage: develop, alpha, beta, or stable. Controls which platform tests run."
required: false
type: string
default: ""
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
install-check:
@@ -182,10 +193,16 @@ jobs:
fi
checks-windows:
# Windows tests: beta+ staging only (not on regular PRs to save compute)
if: |
inputs.test_stage == 'beta' ||
inputs.test_stage == 'stable'
runs-on: blacksmith-4vcpu-windows-2025
env:
NODE_OPTIONS: --max-old-space-size=4096
CLAWDBOT_TEST_WORKERS: 1
# Keep total concurrency predictable on the 4 vCPU runner:
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
OPENCLAW_TEST_WORKERS: 2
defaults:
run:
shell: bash
@@ -208,6 +225,25 @@ jobs:
with:
submodules: false
- name: Try to exclude workspace from Windows Defender (best-effort)
shell: pwsh
run: |
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
if (-not $cmd) {
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
exit 0
}
try {
# Defender sometimes intercepts process spawning (vitest workers). If this fails
# (eg hardened images), keep going and rely on worker limiting above.
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
Write-Host "Defender exclusions applied."
} catch {
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
}
- name: Checkout submodules (retry)
run: |
set -euo pipefail
@@ -270,14 +306,9 @@ jobs:
run: ${{ matrix.command }}
checks-macos:
if: github.event_name == 'pull_request'
# macOS tests: stable staging only (not on regular PRs to save compute)
if: inputs.test_stage == 'stable'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: test
command: pnpm test
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -297,6 +328,7 @@ jobs:
done
exit 1
# --- Node/pnpm setup (for TS tests) ---
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -336,71 +368,20 @@ jobs:
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
# --- Run all checks sequentially (fast gates first) ---
- name: TS tests (macOS)
env:
NODE_OPTIONS: --max-old-space-size=4096
run: ${{ matrix.command }}
macos-app:
if: github.event_name == 'pull_request'
runs-on: macos-latest
strategy:
fail-fast: false
matrix:
include:
- task: lint
command: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- task: build
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- task: test
command: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
run: pnpm test
# --- Xcode/Swift setup ---
- name: Select Xcode 26.1
run: |
sudo xcode-select -s /Applications/Xcode_26.1.app
xcodebuild -version
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: |
brew install xcodegen swiftlint swiftformat
run: brew install xcodegen swiftlint swiftformat
- name: Show toolchain
run: |
@@ -408,8 +389,35 @@ jobs:
xcodebuild -version
swift --version
- name: Run ${{ matrix.task }}
run: ${{ matrix.command }}
- name: Swift lint
run: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- name: Swift build (release)
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift build --package-path apps/macos --configuration release; then
exit 0
fi
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- name: Swift test
run: |
set -euo pipefail
for attempt in 1 2 3; do
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
ios:
if: false # ignore iOS in CI for now
runs-on: macos-latest

View File

@@ -0,0 +1,349 @@
name: Deployment Strategy
# Reusable deployment workflow for staged releases
#
# Deployment targets by stage:
# - alpha: npm @alpha tag only
# - beta: npm @beta tag + Docker (ghcr.io) beta tag
# - stable: npm @latest + Docker latest + multi-arch manifest
on:
workflow_call:
inputs:
deployment_stage:
description: "Deployment stage: alpha, beta, or stable"
required: true
type: string
app_version:
description: "Version of the application to deploy"
required: true
type: string
source_branch:
description: "Source branch for deployment"
required: true
type: string
outputs:
deployment_status:
description: "Status of the deployment"
value: ${{ jobs.deploy-summary.outputs.status }}
npm_url:
description: "npm package URL"
value: ${{ jobs.deploy-summary.outputs.npm_url }}
docker_url:
description: "Docker image URL"
value: ${{ jobs.deploy-summary.outputs.docker_url }}
secrets:
NPM_TOKEN:
required: false
DISCORD_WEBHOOK_URL:
required: false
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# npm publish (all stages)
npm-publish:
name: npm Publish (${{ inputs.deployment_stage }})
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
status: ${{ steps.publish.outputs.status }}
npm_url: ${{ steps.publish.outputs.npm_url }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_branch }}
submodules: false
- name: Checkout submodules (retry)
run: |
set -euo pipefail
git submodule sync --recursive
for attempt in 1 2 3 4 5; do
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
exit 0
fi
echo "Submodule update failed (attempt $attempt/5). Retrying…"
sleep $((attempt * 10))
done
exit 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: "https://registry.npmjs.org"
- name: Setup pnpm (corepack retry)
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare pnpm@10.23.0 --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Build
run: pnpm build
- name: Determine npm tag
id: npm-tag
run: |
case "${{ inputs.deployment_stage }}" in
alpha)
echo "tag=alpha" >> $GITHUB_OUTPUT
;;
beta)
echo "tag=beta" >> $GITHUB_OUTPUT
;;
stable)
echo "tag=latest" >> $GITHUB_OUTPUT
;;
esac
- name: Publish to npm
id: publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NPM_TOKEN not set, skipping publish"
echo "status=skipped" >> $GITHUB_OUTPUT
echo "npm_url=" >> $GITHUB_OUTPUT
exit 0
fi
NPM_TAG="${{ steps.npm-tag.outputs.tag }}"
if npm publish --tag "$NPM_TAG" --access public; then
echo "status=success" >> $GITHUB_OUTPUT
echo "npm_url=https://www.npmjs.com/package/openclaw/v/${{ inputs.app_version }}" >> $GITHUB_OUTPUT
else
echo "status=failed" >> $GITHUB_OUTPUT
echo "npm_url=" >> $GITHUB_OUTPUT
exit 1
fi
# Docker build - amd64 (beta+ only)
docker-amd64:
name: Docker amd64 (${{ inputs.deployment_stage }})
if: inputs.deployment_stage != 'alpha'
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_branch }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ inputs.app_version }}-amd64
type=raw,value=${{ inputs.deployment_stage }}-amd64
- name: Build and push amd64
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Docker build - arm64 (beta+ only)
docker-arm64:
name: Docker arm64 (${{ inputs.deployment_stage }})
if: inputs.deployment_stage != 'alpha'
runs-on: ubuntu-24.04-arm
permissions:
packages: write
contents: read
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_branch }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ inputs.app_version }}-arm64
type=raw,value=${{ inputs.deployment_stage }}-arm64
- name: Build and push arm64
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Create multi-arch manifest (beta+ only)
docker-manifest:
name: Docker Manifest (${{ inputs.deployment_stage }})
if: inputs.deployment_stage != 'alpha'
runs-on: ubuntu-latest
needs: [docker-amd64, docker-arm64]
permissions:
packages: write
contents: read
outputs:
docker_url: ${{ steps.manifest.outputs.docker_url }}
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest
id: manifest
run: |
STAGE="${{ inputs.deployment_stage }}"
VERSION="${{ inputs.app_version }}"
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
# Create version manifest
docker buildx imagetools create \
-t "${IMAGE}:${VERSION}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
# Create stage manifest (beta or latest)
if [ "$STAGE" = "stable" ]; then
docker buildx imagetools create \
-t "${IMAGE}:latest" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
echo "docker_url=${IMAGE}:latest" >> $GITHUB_OUTPUT
else
docker buildx imagetools create \
-t "${IMAGE}:${STAGE}" \
"${IMAGE}:${VERSION}-amd64" \
"${IMAGE}:${VERSION}-arm64"
echo "docker_url=${IMAGE}:${STAGE}" >> $GITHUB_OUTPUT
fi
# Deployment summary
deploy-summary:
name: Deployment Summary
runs-on: ubuntu-latest
needs: [npm-publish, docker-manifest]
if: "!cancelled()"
outputs:
status: ${{ steps.summary.outputs.status }}
npm_url: ${{ steps.summary.outputs.npm_url }}
docker_url: ${{ steps.summary.outputs.docker_url }}
steps:
- name: Summarize deployment
id: summary
run: |
NPM_STATUS="${{ needs.npm-publish.outputs.status || 'skipped' }}"
NPM_URL="${{ needs.npm-publish.outputs.npm_url }}"
DOCKER_URL="${{ needs.docker-manifest.outputs.docker_url || '' }}"
echo "npm_url=$NPM_URL" >> $GITHUB_OUTPUT
echo "docker_url=$DOCKER_URL" >> $GITHUB_OUTPUT
if [ "$NPM_STATUS" = "success" ] || [ "$NPM_STATUS" = "skipped" ]; then
echo "status=success" >> $GITHUB_OUTPUT
else
echo "status=failed" >> $GITHUB_OUTPUT
fi
# Generate summary
echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Stage | ${{ inputs.deployment_stage }} |" >> $GITHUB_STEP_SUMMARY
echo "| Version | ${{ inputs.app_version }} |" >> $GITHUB_STEP_SUMMARY
echo "| npm | $NPM_STATUS |" >> $GITHUB_STEP_SUMMARY
echo "| Docker | ${{ needs.docker-manifest.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
# Discord notification
notify:
name: Discord Notification
needs: deploy-summary
if: "!cancelled()"
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord success notification
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.deploy-summary.outputs.status == 'success' }}
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "🚀 Deployed: ${{ inputs.deployment_stage }} v${{ inputs.app_version }}"
description: |
**npm**: ${{ needs.deploy-summary.outputs.npm_url || 'skipped' }}
**Docker**: ${{ needs.deploy-summary.outputs.docker_url || 'skipped' }}
color: "3066993"
- name: Discord failure notification
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.deploy-summary.outputs.status != 'success' }}
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "❌ Deployment Failed: ${{ inputs.deployment_stage }}"
description: |
**Version**: ${{ inputs.app_version }}
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
color: "15158332"

94
.github/workflows/feature-pr.yml vendored Normal file
View File

@@ -0,0 +1,94 @@
name: Feature PR
# Auto-create PR from dev/* branches to develop
# This is the entry point for new features into the staging pipeline
on:
push:
branches:
- "dev/**"
- "feature/**"
- "fix/**"
concurrency:
group: feature-pr-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: write
jobs:
create-pr:
name: Create PR to develop
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure develop branch exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if git ls-remote --heads origin develop | grep -q develop; then
echo "develop branch already exists"
else
echo "develop branch does not exist — creating from main"
git push origin origin/main:refs/heads/develop
fi
- name: Check for existing PR
id: check-pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="${{ github.ref_name }}"
TARGET="develop"
# Check if PR already exists
EXISTING=$(gh pr list --head "$BRANCH" --base "$TARGET" --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
echo "PR #$EXISTING already exists for $BRANCH → $TARGET"
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create PR
if: steps.check-pr.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="${{ github.ref_name }}"
TARGET="develop"
# Extract title from branch name (dev/foo-bar → foo bar)
TITLE=$(echo "$BRANCH" | sed 's|^dev/||; s|^feature/||; s|^fix/||; s|-| |g; s|_| |g')
# Capitalize first letter
TITLE="$(echo "${TITLE:0:1}" | tr '[:lower:]' '[:upper:]')${TITLE:1}"
# Create PR body
BODY=$(cat << 'PRBODY'
Auto-created PR from feature branch.
## Changes
<!-- Describe your changes here -->
---
*This PR was auto-created by the feature-pr workflow.*
PRBODY
)
gh pr create \
--base "$TARGET" \
--head "$BRANCH" \
--title "$TITLE" \
--body "$BODY"
echo "Created PR: $BRANCH → $TARGET"

View File

@@ -3,6 +3,10 @@ name: Formal models (informational conformance)
on:
pull_request:
concurrency:
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
cancel-in-progress: true
jobs:
formal_conformance:
runs-on: ubuntu-latest

129
.github/workflows/generate-changelog.yml vendored Normal file
View File

@@ -0,0 +1,129 @@
name: Generate Changelog
on:
workflow_call:
inputs:
version:
description: "Version for the changelog"
required: true
type: string
release_type:
description: "Release type: alpha, beta, or stable"
required: true
type: string
outputs:
changelog:
description: "Generated changelog content"
value: ${{ jobs.generate.outputs.changelog }}
changelog_file:
description: "Path to changelog file"
value: ${{ jobs.generate.outputs.changelog_file }}
jobs:
generate:
name: Generate Changelog
runs-on: ubuntu-latest
outputs:
changelog: ${{ steps.generate.outputs.changelog }}
changelog_file: ${{ steps.generate.outputs.changelog_file }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: generate
run: |
VERSION="${{ inputs.version }}"
RELEASE_TYPE="${{ inputs.release_type }}"
DATE=$(date +%Y-%m-%d)
# Start building changelog
CHANGELOG="## v${VERSION} (${DATE})\n\n"
# Initialize sections
FEATURES=""
FIXES=""
DOCS=""
CHORES=""
OTHER=""
# Get commits since last tag
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -n "$LATEST_TAG" ]; then
COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --format="%s")
else
COMMITS=$(git log --oneline --format="%s" | head -50)
fi
# Categorize commits by conventional commit type
while IFS= read -r commit; do
if [ -z "$commit" ]; then
continue
fi
# Extract type from conventional commit
if [[ "$commit" =~ ^feat(\(.+\))?:\ (.+)$ ]]; then
FEATURES="${FEATURES}- ${BASH_REMATCH[2]}\n"
elif [[ "$commit" =~ ^fix(\(.+\))?:\ (.+)$ ]]; then
FIXES="${FIXES}- ${BASH_REMATCH[2]}\n"
elif [[ "$commit" =~ ^docs(\(.+\))?:\ (.+)$ ]]; then
DOCS="${DOCS}- ${BASH_REMATCH[2]}\n"
elif [[ "$commit" =~ ^chore(\(.+\))?:\ (.+)$ ]]; then
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
elif [[ "$commit" =~ ^refactor(\(.+\))?:\ (.+)$ ]]; then
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
elif [[ "$commit" =~ ^test(\(.+\))?:\ (.+)$ ]]; then
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
elif [[ "$commit" =~ ^ci(\(.+\))?:\ (.+)$ ]]; then
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
else
# Non-conventional commit, add to other
OTHER="${OTHER}- ${commit}\n"
fi
done <<< "$COMMITS"
# Build final changelog
if [ -n "$FEATURES" ]; then
CHANGELOG="${CHANGELOG}### ✨ Features\n\n${FEATURES}\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG="${CHANGELOG}### 🐛 Bug Fixes\n\n${FIXES}\n"
fi
if [ -n "$DOCS" ]; then
CHANGELOG="${CHANGELOG}### 📚 Documentation\n\n${DOCS}\n"
fi
if [ -n "$CHORES" ]; then
CHANGELOG="${CHANGELOG}### 🔧 Maintenance\n\n${CHORES}\n"
fi
if [ -n "$OTHER" ]; then
CHANGELOG="${CHANGELOG}### Other Changes\n\n${OTHER}\n"
fi
# If no categorized commits, add a simple message
if [ -z "$FEATURES" ] && [ -z "$FIXES" ] && [ -z "$DOCS" ] && [ -z "$CHORES" ] && [ -z "$OTHER" ]; then
CHANGELOG="${CHANGELOG}No notable changes in this release.\n"
fi
# Add release metadata
CHANGELOG="${CHANGELOG}\n---\n\n"
CHANGELOG="${CHANGELOG}**Release Type**: ${RELEASE_TYPE}\n"
CHANGELOG="${CHANGELOG}**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LATEST_TAG:-initial}...v${VERSION}\n"
# Escape for multiline output (random delimiter prevents collision with commit messages)
DELIMITER="CHANGELOG_$(openssl rand -hex 16)"
echo "changelog<<${DELIMITER}" >> $GITHUB_OUTPUT
echo -e "$CHANGELOG" >> $GITHUB_OUTPUT
echo "${DELIMITER}" >> $GITHUB_OUTPUT
echo "changelog_file=CHANGELOG.md" >> $GITHUB_OUTPUT
# Also write to step summary
echo "## Generated Changelog" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo -e "$CHANGELOG" >> $GITHUB_STEP_SUMMARY

93
.github/workflows/hotfix-pr.yml vendored Normal file
View File

@@ -0,0 +1,93 @@
name: Hotfix PR
# Emergency hotfix workflow - bypasses staging pipeline
# Use for critical security fixes or production-breaking bugs only
#
# Flow: hotfix/* → main (directly, with expedited review)
on:
push:
branches:
- "hotfix/**"
concurrency:
group: hotfix-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
create-pr:
name: Create Hotfix PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for existing PR
id: check-pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="${{ github.ref_name }}"
EXISTING=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
echo "Hotfix PR #$EXISTING already exists"
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create Hotfix PR
if: steps.check-pr.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH="${{ github.ref_name }}"
# Extract title from branch name
TITLE=$(echo "$BRANCH" | sed 's|^hotfix/||; s|-| |g; s|_| |g')
TITLE="🚨 HOTFIX: $(echo "${TITLE:0:1}" | tr '[:lower:]' '[:upper:]')${TITLE:1}"
# Create PR body
BODY=$(cat << 'PRBODY'
## 🚨 Emergency Hotfix
**This PR bypasses the normal staging pipeline.**
### What's broken?
<!-- Describe the production issue -->
### Root cause
<!-- Brief explanation of what went wrong -->
### Fix
<!-- What this hotfix does -->
### Verification
- [ ] Tested locally
- [ ] Reviewed by at least one other maintainer
- [ ] Post-merge monitoring plan in place
---
⚠️ **After merging:** Cherry-pick this fix to `develop`, `alpha`, and `beta` branches to keep them in sync.
*This PR was auto-created by the hotfix-pr workflow.*
PRBODY
)
gh pr create \
--base main \
--head "$BRANCH" \
--title "$TITLE" \
--label "hotfix,priority:critical" \
--body "$BODY"
echo "Created hotfix PR: $BRANCH → main"

View File

@@ -6,6 +6,10 @@ on:
pull_request:
workflow_dispatch:
concurrency:
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
install-smoke:
runs-on: ubuntu-latest

319
.github/workflows/promote-branch.yml vendored Normal file
View File

@@ -0,0 +1,319 @@
name: Promote Branch
# Staged branch promotion for openclaw:
#
# develop → alpha → beta → main
#
# - External contributors: target `develop`
# - develop → alpha: auto-creates PR after core checks pass
# - alpha → beta: auto-creates PR after alpha tests pass (+ secrets scan)
# - beta → main: auto-creates PR after full tests pass (+ Windows)
#
# Merging to main triggers a release (handled separately by release workflow)
on:
push:
branches:
- develop
- alpha
- beta
paths-ignore:
- "docs/**"
- "*.md"
workflow_dispatch:
inputs:
source_branch:
description: "Source branch to promote from"
required: true
type: choice
options:
- develop
- alpha
- beta
skip_tests:
description: "Skip tests (use with caution)"
required: false
type: boolean
default: false
concurrency:
group: promote-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
# Determine promotion target
determine-target:
name: Determine Target Branch
runs-on: ubuntu-latest
outputs:
source: ${{ steps.determine.outputs.source }}
target: ${{ steps.determine.outputs.target }}
test_stage: ${{ steps.determine.outputs.test_stage }}
should_promote: ${{ steps.determine.outputs.should_promote }}
steps:
- name: Determine promotion target
id: determine
run: |
# Get source branch
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
SOURCE="${{ inputs.source_branch }}"
else
SOURCE="${{ github.ref_name }}"
fi
echo "source=$SOURCE" >> $GITHUB_OUTPUT
case "$SOURCE" in
develop)
echo "target=alpha" >> $GITHUB_OUTPUT
echo "test_stage=develop" >> $GITHUB_OUTPUT
echo "should_promote=true" >> $GITHUB_OUTPUT
;;
alpha)
echo "target=beta" >> $GITHUB_OUTPUT
echo "test_stage=alpha" >> $GITHUB_OUTPUT
echo "should_promote=true" >> $GITHUB_OUTPUT
;;
beta)
echo "target=main" >> $GITHUB_OUTPUT
echo "test_stage=beta" >> $GITHUB_OUTPUT
echo "should_promote=true" >> $GITHUB_OUTPUT
;;
*)
echo "target=" >> $GITHUB_OUTPUT
echo "test_stage=" >> $GITHUB_OUTPUT
echo "should_promote=false" >> $GITHUB_OUTPUT
;;
esac
# Ensure target branch exists (create from main if not)
ensure-target-branch:
name: Ensure Target Branch
needs: determine-target
if: needs.determine-target.outputs.should_promote == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create target branch if missing
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TARGET="${{ needs.determine-target.outputs.target }}"
if git ls-remote --exit-code origin "refs/heads/$TARGET" >/dev/null 2>&1; then
echo "Branch '$TARGET' already exists"
else
echo "Branch '$TARGET' does not exist — creating from main"
git push origin "origin/main:refs/heads/$TARGET"
fi
# Run stage-appropriate tests
run-tests:
name: Run Tests
needs: [determine-target, ensure-target-branch]
if: ${{ needs.determine-target.outputs.should_promote == 'true' && (github.event_name != 'workflow_dispatch' || !inputs.skip_tests) }}
uses: ./.github/workflows/testing-strategy.yml
with:
test_stage: ${{ needs.determine-target.outputs.test_stage }}
app_version: ${{ github.sha }}
secrets: inherit
# Create promotion PR
create-promotion-pr:
name: Create Promotion PR
needs: [determine-target, ensure-target-branch, run-tests]
if: |
!cancelled() &&
needs.determine-target.outputs.should_promote == 'true' &&
(needs.run-tests.outputs.test_status == 'passed' || (github.event_name == 'workflow_dispatch' && inputs.skip_tests))
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.output-pr.outputs.pull-request-number }}
pr_url: ${{ steps.output-pr.outputs.pull-request-url }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ needs.determine-target.outputs.source }}
fetch-depth: 0
- name: Get commit info
id: commits
run: |
TARGET="${{ needs.determine-target.outputs.target }}"
# Fetch target branch
git fetch origin $TARGET 2>/dev/null || true
# Get commits not in target
if git rev-parse origin/$TARGET >/dev/null 2>&1; then
COMMIT_COUNT=$(git rev-list --count origin/$TARGET..HEAD 2>/dev/null || echo "0")
COMMIT_SUMMARY=$(git log origin/$TARGET..HEAD --oneline --format="- %s (%h)" 2>/dev/null | head -20 || echo "Initial promotion")
else
COMMIT_COUNT=$(git rev-list --count HEAD 2>/dev/null || echo "0")
COMMIT_SUMMARY=$(git log --oneline --format="- %s (%h)" 2>/dev/null | head -20 || echo "Initial promotion")
fi
echo "count=$COMMIT_COUNT" >> $GITHUB_OUTPUT
DELIM="COMMITS_$(openssl rand -hex 16)"
echo "summary<<${DELIM}" >> $GITHUB_OUTPUT
echo "$COMMIT_SUMMARY" >> $GITHUB_OUTPUT
echo "${DELIM}" >> $GITHUB_OUTPUT
- name: Check for existing PR
id: check-pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SOURCE="${{ needs.determine-target.outputs.source }}"
TARGET="${{ needs.determine-target.outputs.target }}"
EXISTING=$(gh pr list --head "$SOURCE" --base "$TARGET" --json number --jq '.[0].number // empty')
if [ -n "$EXISTING" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
echo "pr_url=https://github.com/${{ github.repository }}/pull/$EXISTING" >> $GITHUB_OUTPUT
echo "Promotion PR #$EXISTING already exists for $SOURCE → $TARGET"
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
id: create-pr
if: steps.check-pr.outputs.exists != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
SOURCE="${{ needs.determine-target.outputs.source }}"
TARGET="${{ needs.determine-target.outputs.target }}"
TEST_STAGE="${{ needs.determine-target.outputs.test_stage }}"
COMMIT_COUNT="${{ steps.commits.outputs.count }}"
# Write PR body to a temp file to avoid shell quoting issues
BODY_FILE=$(mktemp)
cat > "$BODY_FILE" <<__PRBODY__
## Staged Promotion
| Property | Value |
|----------|-------|
| Source | \`${SOURCE}\` |
| Target | \`${TARGET}\` |
| Test Stage | \`${TEST_STAGE}\` |
### Changes (${COMMIT_COUNT} commits)
${{ steps.commits.outputs.summary }}
### Checklist
- [ ] Changes reviewed
- [ ] CI passing
- [ ] Ready to promote
---
*Auto-generated by the branch promotion workflow.*
__PRBODY__
PR_URL=$(gh pr create \
--base "$TARGET" \
--head "$SOURCE" \
--title "🚀 Promote: $SOURCE → $TARGET" \
--body-file "$BODY_FILE" \
--label "promotion")
rm -f "$BODY_FILE"
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
echo "Created promotion PR: $SOURCE → $TARGET"
- name: Output existing PR
id: output-pr
run: |
if [ "${{ steps.check-pr.outputs.exists }}" = "true" ]; then
echo "pull-request-number=${{ steps.check-pr.outputs.pr_number }}" >> $GITHUB_OUTPUT
echo "pull-request-url=${{ steps.check-pr.outputs.pr_url }}" >> $GITHUB_OUTPUT
else
echo "pull-request-number=${{ steps.create-pr.outputs.pr_number }}" >> $GITHUB_OUTPUT
echo "pull-request-url=${{ steps.create-pr.outputs.pr_url }}" >> $GITHUB_OUTPUT
fi
# Auto-merge for develop → alpha (fast-track, new PRs only)
auto-merge:
name: Auto-merge (develop → alpha)
needs: [determine-target, create-promotion-pr]
if: |
needs.determine-target.outputs.source == 'develop' &&
needs.create-promotion-pr.outputs.pr_number != '' &&
needs.create-promotion-pr.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Enable auto-merge
run: |
gh pr merge ${{ needs.create-promotion-pr.outputs.pr_number }} \
--auto \
--squash \
--repo ${{ github.repository }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Notify about promotion
notify-promotion:
name: Notify Promotion
needs: [determine-target, create-promotion-pr]
if: "!cancelled() && needs.create-promotion-pr.outputs.pr_url != ''"
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord notification
if: env.DISCORD_WEBHOOK_URL != ''
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "🔄 Promotion PR: ${{ needs.determine-target.outputs.source }} → ${{ needs.determine-target.outputs.target }}"
description: |
**PR**: ${{ needs.create-promotion-pr.outputs.pr_url }}
**Stage**: ${{ needs.determine-target.outputs.test_stage }}
color: "3447003"
# Handle failed tests
notify-failure:
name: Notify Test Failure
needs: [determine-target, run-tests]
if: |
!cancelled() &&
needs.run-tests.outputs.test_status != 'passed' &&
!inputs.skip_tests
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord notification
if: env.DISCORD_WEBHOOK_URL != ''
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "❌ Promotion Blocked: ${{ needs.determine-target.outputs.source }}"
description: |
**Target**: ${{ needs.determine-target.outputs.target }}
**Reason**: Tests failed
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
color: "15158332"

View File

@@ -0,0 +1,264 @@
name: Release Orchestrator
# Orchestrates staged releases for openclaw
#
# This workflow is called when code is promoted to main (stable release)
# or can be triggered manually for alpha/beta releases from their branches.
#
# Flow: version → changelog → test → deploy → release
on:
push:
branches:
- main
paths-ignore:
- "docs/**"
- "*.md"
- ".github/workflows/docs-*.yml"
workflow_call:
inputs:
release_type:
description: "Release type: alpha, beta, or stable"
required: true
type: string
source_branch:
description: "Source branch for the release"
required: true
type: string
dry_run:
description: "Perform a dry run without publishing"
required: false
type: boolean
default: false
outputs:
version:
description: "The released version"
value: ${{ jobs.version.outputs.new_version }}
release_url:
description: "URL to the GitHub release"
value: ${{ jobs.release.outputs.release_url }}
status:
description: "Release status"
value: ${{ jobs.release.outputs.status }}
secrets:
NPM_TOKEN:
required: false
DISCORD_WEBHOOK_URL:
required: false
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: write
packages: write
jobs:
# Determine release parameters (push vs workflow_call)
determine-params:
name: Determine Parameters
runs-on: ubuntu-latest
outputs:
release_type: ${{ steps.params.outputs.release_type }}
source_branch: ${{ steps.params.outputs.source_branch }}
dry_run: ${{ steps.params.outputs.dry_run }}
steps:
- name: Set parameters
id: params
run: |
# When triggered by push to main, use stable defaults
if [ "${{ github.event_name }}" = "push" ]; then
echo "release_type=stable" >> $GITHUB_OUTPUT
echo "source_branch=main" >> $GITHUB_OUTPUT
echo "dry_run=false" >> $GITHUB_OUTPUT
else
# workflow_call - use provided inputs
echo "release_type=${{ inputs.release_type }}" >> $GITHUB_OUTPUT
echo "source_branch=${{ inputs.source_branch }}" >> $GITHUB_OUTPUT
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT
fi
# Get commits since last release
get-commits:
name: Get Commits
needs: determine-params
runs-on: ubuntu-latest
outputs:
commits: ${{ steps.commits.outputs.commits }}
has_changes: ${{ steps.commits.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ needs.determine-params.outputs.source_branch }}
- name: Get commits since last tag
id: commits
run: |
# Get latest tag for this release type
case "${{ needs.determine-params.outputs.release_type }}" in
alpha)
PATTERN="v*-alpha.*"
;;
beta)
PATTERN="v*-beta.*"
;;
stable)
PATTERN="v[0-9]*.[0-9]*.[0-9]*"
;;
esac
# Filter out prerelease tags for stable (glob * matches -alpha/-beta suffixes)
if [ "${{ needs.determine-params.outputs.release_type }}" = "stable" ]; then
LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | grep -v -E '-(alpha|beta)\.' | head -1)
else
LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | head -1)
fi
if [ -z "$LATEST_TAG" ]; then
# No previous tag, use all commits
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
echo "No previous ${{ needs.determine-params.outputs.release_type }} tag found, using initial commit"
else
echo "Latest ${{ needs.determine-params.outputs.release_type }} tag: $LATEST_TAG"
fi
COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --format="- %s (%h)")
if [ -z "$COMMITS" ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "commits=" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
DELIM="COMMITS_$(openssl rand -hex 16)"
echo "commits<<${DELIM}" >> $GITHUB_OUTPUT
echo "$COMMITS" >> $GITHUB_OUTPUT
echo "${DELIM}" >> $GITHUB_OUTPUT
fi
# Version operations
version:
name: Version
needs: [determine-params, get-commits]
if: needs.get-commits.outputs.has_changes == 'true'
uses: ./.github/workflows/version-operations.yml
with:
release_type: ${{ needs.determine-params.outputs.release_type }}
source_branch: ${{ needs.determine-params.outputs.source_branch }}
should_bump: true
dry_run: ${{ needs.determine-params.outputs.dry_run }}
# Generate changelog
changelog:
name: Changelog
needs: [determine-params, get-commits, version]
if: needs.get-commits.outputs.has_changes == 'true'
uses: ./.github/workflows/generate-changelog.yml
with:
version: ${{ needs.version.outputs.new_version }}
release_type: ${{ needs.determine-params.outputs.release_type }}
# Run full test suite for the release type
test:
name: Test
needs: [determine-params, get-commits, version]
if: needs.get-commits.outputs.has_changes == 'true'
uses: ./.github/workflows/testing-strategy.yml
with:
test_stage: ${{ needs.determine-params.outputs.release_type }}
app_version: ${{ needs.version.outputs.new_version }}
secrets: inherit
# Deploy (npm + Docker)
deploy:
name: Deploy
needs: [determine-params, version, test]
if: ${{ needs.determine-params.outputs.dry_run != 'true' && needs.test.outputs.test_status == 'passed' }}
uses: ./.github/workflows/deployment-strategy.yml
with:
deployment_stage: ${{ needs.determine-params.outputs.release_type }}
app_version: ${{ needs.version.outputs.new_version }}
source_branch: ${{ needs.determine-params.outputs.source_branch }}
secrets: inherit
# Create GitHub release
release:
name: GitHub Release
needs: [determine-params, version, changelog, deploy]
if: ${{ needs.determine-params.outputs.dry_run != 'true' }}
runs-on: ubuntu-latest
outputs:
release_url: ${{ steps.create-release.outputs.html_url }}
status: ${{ steps.status.outputs.status }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ needs.determine-params.outputs.source_branch }}
- name: Create GitHub Release
id: create-release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ needs.version.outputs.new_version }}
name: openclaw ${{ needs.version.outputs.new_version }}
body: ${{ needs.changelog.outputs.changelog }}
prerelease: ${{ needs.determine-params.outputs.release_type != 'stable' }}
draft: false
- name: Set status
id: status
run: echo "status=success" >> $GITHUB_OUTPUT
# Notify on success
notify-success:
name: Notify Success
needs: [determine-params, version, release]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord notification
if: env.DISCORD_WEBHOOK_URL != ''
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "🎉 Released: openclaw v${{ needs.version.outputs.new_version }}"
description: |
**Type**: ${{ needs.determine-params.outputs.release_type }}
**Release**: ${{ needs.release.outputs.release_url }}
color: "3066993"
# Notify on failure
notify-failure:
name: Notify Failure
needs: [determine-params, version, test, deploy, release]
if: |
!cancelled() &&
needs.version.result != 'skipped' &&
(needs.test.result == 'failure' || needs.deploy.result == 'failure' || needs.release.result == 'failure')
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord notification
if: env.DISCORD_WEBHOOK_URL != ''
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "❌ Release Failed: ${{ needs.determine-params.outputs.release_type }}"
description: |
**Branch**: ${{ needs.determine-params.outputs.source_branch }}
**Tests**: ${{ needs.test.outputs.test_status }}
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
color: "15158332"

51
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Release
# Manual release workflow - triggers the release orchestrator
#
# Branch → Release Type mapping:
# alpha → releases from 'alpha' branch with -alpha.N suffix
# beta → releases from 'beta' branch with -beta.N suffix
# stable → releases from 'main' branch with YYYY.M.D version
on:
workflow_dispatch:
inputs:
release_type:
description: "Release type"
required: true
type: choice
options:
- alpha
- beta
- stable
default: "alpha"
dry_run:
description: "Dry run (no publish)"
required: false
type: boolean
default: false
jobs:
determine-branch:
runs-on: ubuntu-latest
outputs:
branch: ${{ steps.branch.outputs.name }}
steps:
- name: Determine source branch
id: branch
run: |
case "${{ inputs.release_type }}" in
alpha) echo "name=alpha" >> $GITHUB_OUTPUT ;;
beta) echo "name=beta" >> $GITHUB_OUTPUT ;;
stable) echo "name=main" >> $GITHUB_OUTPUT ;;
esac
release:
name: Release
needs: determine-branch
uses: ./.github/workflows/release-orchestrator.yml
with:
release_type: ${{ inputs.release_type }}
source_branch: ${{ needs.determine-branch.outputs.branch }}
dry_run: ${{ inputs.dry_run }}
secrets: inherit

256
.github/workflows/rollback.yml vendored Normal file
View File

@@ -0,0 +1,256 @@
name: Rollback
# Emergency rollback workflow
#
# Reverts npm + Docker to a previous known-good version.
# Does NOT revert git — the bad commits stay in history.
# Create a hotfix branch to fix forward after rolling back.
#
# What it does:
# 1. Re-tags the previous version as @latest / :latest on npm + Docker
# 2. Creates a GitHub release noting the rollback
# 3. Notifies Discord
#
# What it does NOT do:
# - Revert git commits (fix forward instead)
# - Remove the bad version from npm (use `npm unpublish` manually if needed)
on:
workflow_dispatch:
inputs:
rollback_to:
description: "Version to roll back to (e.g. 2026.2.5)"
required: true
type: string
reason:
description: "Reason for rollback"
required: true
type: string
rollback_npm:
description: "Roll back npm dist-tag"
required: false
type: boolean
default: true
rollback_docker:
description: "Roll back Docker :latest tag"
required: false
type: boolean
default: true
concurrency:
group: rollback
cancel-in-progress: false
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Validate the target version exists
validate:
name: Validate Target Version
runs-on: ubuntu-latest
outputs:
tag_exists: ${{ steps.check.outputs.tag_exists }}
npm_exists: ${{ steps.check.outputs.npm_exists }}
docker_exists: ${{ steps.check.outputs.docker_exists }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate version
id: check
run: |
VERSION="${{ inputs.rollback_to }}"
# Check git tag
if git tag -l "v${VERSION}" | grep -q .; then
echo "tag_exists=true" >> $GITHUB_OUTPUT
echo "✅ Git tag v${VERSION} exists"
else
echo "tag_exists=false" >> $GITHUB_OUTPUT
echo "❌ Git tag v${VERSION} not found"
fi
# Check npm
if npm view "openclaw@${VERSION}" version 2>/dev/null | grep -q "${VERSION}"; then
echo "npm_exists=true" >> $GITHUB_OUTPUT
echo "✅ npm version ${VERSION} exists"
else
echo "npm_exists=false" >> $GITHUB_OUTPUT
echo "⚠️ npm version ${VERSION} not found (npm rollback will be skipped)"
fi
# Check Docker
if docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}" >/dev/null 2>&1; then
echo "docker_exists=true" >> $GITHUB_OUTPUT
echo "✅ Docker image ${VERSION} exists"
else
echo "docker_exists=false" >> $GITHUB_OUTPUT
echo "⚠️ Docker image ${VERSION} not found (Docker rollback will be skipped)"
fi
- name: Fail if tag doesn't exist
if: steps.check.outputs.tag_exists != 'true'
run: |
echo "::error::Version v${{ inputs.rollback_to }} does not exist as a git tag"
exit 1
# Roll back npm dist-tag
rollback-npm:
name: Rollback npm
needs: validate
if: ${{ inputs.rollback_npm && needs.validate.outputs.npm_exists == 'true' }}
runs-on: ubuntu-latest
outputs:
status: ${{ steps.rollback.outputs.status }}
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
registry-url: "https://registry.npmjs.org"
- name: Roll back npm @latest tag
id: rollback
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
VERSION="${{ inputs.rollback_to }}"
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "::warning::NPM_TOKEN not set, skipping npm rollback"
echo "status=skipped" >> $GITHUB_OUTPUT
exit 0
fi
# Move the @latest dist-tag to the rollback version
if npm dist-tag add "openclaw@${VERSION}" latest; then
echo "status=success" >> $GITHUB_OUTPUT
echo "✅ npm @latest now points to ${VERSION}"
# Show current dist-tags for verification
npm dist-tag ls openclaw
else
echo "status=failed" >> $GITHUB_OUTPUT
echo "::error::Failed to update npm dist-tag"
exit 1
fi
# Roll back Docker :latest tag
rollback-docker:
name: Rollback Docker
needs: validate
if: ${{ inputs.rollback_docker && needs.validate.outputs.docker_exists == 'true' }}
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
outputs:
status: ${{ steps.rollback.outputs.status }}
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Roll back Docker :latest tag
id: rollback
run: |
VERSION="${{ inputs.rollback_to }}"
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
# Re-tag the rollback version as :latest
if docker buildx imagetools create -t "${IMAGE}:latest" "${IMAGE}:${VERSION}"; then
echo "status=success" >> $GITHUB_OUTPUT
echo "✅ Docker :latest now points to ${VERSION}"
else
echo "status=failed" >> $GITHUB_OUTPUT
echo "::error::Failed to retag Docker image"
exit 1
fi
# Create rollback release note
create-rollback-release:
name: Create Rollback Release
needs: [validate, rollback-npm, rollback-docker]
if: "!cancelled() && needs.validate.result == 'success'"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get current version
id: current
run: |
CURRENT=$(node -p "require('./package.json').version")
echo "version=$CURRENT" >> $GITHUB_OUTPUT
- name: Create rollback release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ inputs.rollback_to }}
name: "⚠️ Rollback to openclaw ${{ inputs.rollback_to }}"
body: |
## ⚠️ Rollback
| Property | Value |
|----------|-------|
| Rolled back from | `${{ steps.current.outputs.version }}` |
| Rolled back to | `${{ inputs.rollback_to }}` |
| Initiated by | @${{ github.actor }} |
### Reason
${{ inputs.reason }}
### Rollback Status
| Target | Status |
|--------|--------|
| npm @latest | ${{ needs.rollback-npm.outputs.status || 'skipped' }} |
| Docker :latest | ${{ needs.rollback-docker.outputs.status || 'skipped' }} |
### Next Steps
1. Investigate the issue in the rolled-back version
2. Create a `hotfix/*` branch with the fix
3. Merge via the hotfix workflow to restore forward progress
---
*This release was created by the rollback workflow.*
make_latest: false
prerelease: false
# Notify
notify:
name: Discord Notification
needs: [validate, rollback-npm, rollback-docker, create-rollback-release]
if: "!cancelled() && needs.validate.result == 'success'"
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord notification
if: env.DISCORD_WEBHOOK_URL != ''
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "⚠️ ROLLBACK: openclaw → v${{ inputs.rollback_to }}"
description: |
**Reason**: ${{ inputs.reason }}
**Initiated by**: @${{ github.actor }}
**npm**: ${{ needs.rollback-npm.outputs.status || 'skipped' }}
**Docker**: ${{ needs.rollback-docker.outputs.status || 'skipped' }}
color: "15105570"

153
.github/workflows/testing-strategy.yml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: Testing Strategy
# Reusable testing workflow for staged releases
# Passes test_stage to ci.yml to control which platform tests run
#
# Progressive test coverage by stage:
# - develop/alpha: core checks + secrets + android
# - beta: + Windows tests
# - stable: + macOS tests, macOS app, install smoke
on:
workflow_call:
inputs:
test_stage:
description: "Testing stage: develop, alpha, beta, or stable"
required: true
type: string
app_version:
description: "Version of the application being tested"
required: false
type: string
default: "dev"
outputs:
test_status:
description: "Overall test status"
value: ${{ jobs.test-summary.outputs.overall_status }}
secrets:
DISCORD_WEBHOOK_URL:
required: false
jobs:
# Run CI with stage-appropriate platform gates
ci:
name: Core CI
uses: ./.github/workflows/ci.yml
with:
test_stage: ${{ inputs.test_stage }}
secrets: inherit
# Install smoke test (stable only)
install-smoke:
name: Install Smoke Test
if: inputs.test_stage == 'stable'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm (corepack retry)
run: |
set -euo pipefail
corepack enable
for attempt in 1 2 3; do
if corepack prepare pnpm@10.23.0 --activate; then
pnpm -v
exit 0
fi
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
sleep $((attempt * 10))
done
exit 1
- name: Install pnpm deps (minimal)
run: pnpm install --ignore-scripts --frozen-lockfile
- name: Run installer smoke tests
env:
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
run: pnpm test:install:smoke
# Test summary
test-summary:
name: Test Summary (${{ inputs.test_stage }})
runs-on: ubuntu-latest
needs: [ci, install-smoke]
if: "!cancelled()"
outputs:
overall_status: ${{ steps.summary.outputs.overall_status }}
steps:
- name: Generate summary
id: summary
run: |
echo "## 🧪 Test Results - ${{ inputs.test_stage }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Test Suite | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| CI (checks + secrets) | ${{ needs.ci.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Install Smoke | ${{ needs.install-smoke.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
# CI must pass (includes platform-specific jobs based on test_stage)
if [ "${{ needs.ci.result }}" != "success" ]; then
echo "overall_status=failed" >> $GITHUB_OUTPUT
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ❌ CI failed" >> $GITHUB_STEP_SUMMARY
exit 0
fi
# Stage-specific checks
STAGE="${{ inputs.test_stage }}"
FAILED=false
if [ "$STAGE" = "stable" ]; then
if [ "${{ needs.install-smoke.result }}" = "failure" ]; then
FAILED=true
fi
fi
if [ "$FAILED" = "true" ]; then
echo "overall_status=failed" >> $GITHUB_OUTPUT
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ❌ Some stage-specific tests failed" >> $GITHUB_STEP_SUMMARY
else
echo "overall_status=passed" >> $GITHUB_OUTPUT
echo "" >> $GITHUB_STEP_SUMMARY
echo "### ✅ All required tests passed!" >> $GITHUB_STEP_SUMMARY
fi
# Discord notifications
notify:
name: Discord Notification
needs: test-summary
if: "!cancelled()"
runs-on: ubuntu-latest
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Discord success notification
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.test-summary.outputs.overall_status == 'passed' }}
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "✅ Tests Passed: ${{ inputs.test_stage }} v${{ inputs.app_version }}"
description: "All tests passed for ${{ inputs.test_stage }} stage!"
color: "3066993"
- name: Discord failure notification
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.test-summary.outputs.overall_status != 'passed' }}
uses: ./.github/actions/discord-notify
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
title: "❌ Tests Failed: ${{ inputs.test_stage }} v${{ inputs.app_version }}"
description: |
Some tests failed for ${{ inputs.test_stage }} stage.
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
color: "15158332"

188
.github/workflows/version-operations.yml vendored Normal file
View File

@@ -0,0 +1,188 @@
name: Version Operations
# Version bump workflow for openclaw
#
# Version format: YYYY.M.D (stable) or YYYY.M.D-{alpha,beta}.N (prerelease)
# Examples: 2026.2.6, 2026.2.6-alpha.1, 2026.2.6-beta.3
on:
workflow_call:
inputs:
release_type:
description: "Release type: alpha, beta, or stable"
required: true
type: string
source_branch:
description: "Source branch"
required: true
type: string
should_bump:
description: "Whether to bump the version"
required: false
type: boolean
default: true
dry_run:
description: "Perform a dry run without committing"
required: false
type: boolean
default: false
outputs:
current_version:
description: "Current version before bump"
value: ${{ jobs.version.outputs.current_version }}
new_version:
description: "New version after bump"
value: ${{ jobs.version.outputs.new_version }}
version_tag:
description: "Version tag (with v prefix)"
value: ${{ jobs.version.outputs.version_tag }}
permissions:
contents: write
jobs:
version:
name: Version Operations
runs-on: ubuntu-latest
outputs:
current_version: ${{ steps.get-version.outputs.current }}
new_version: ${{ steps.bump-version.outputs.new }}
version_tag: ${{ steps.bump-version.outputs.tag }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ inputs.source_branch }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22.x
- name: Get current version
id: get-version
run: |
CURRENT_VERSION=$(node -p "require('./package.json').version")
echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT_VERSION"
- name: Calculate new version
id: bump-version
run: |
CURRENT="${{ steps.get-version.outputs.current }}"
RELEASE_TYPE="${{ inputs.release_type }}"
# Get current date components
YEAR=$(date +%Y)
MONTH=$(date +%-m)
DAY=$(date +%-d)
TODAY="${YEAR}.${MONTH}.${DAY}"
# Parse current version to check if it's today + same type
# Patterns: YYYY.M.D or YYYY.M.D-type.N
if [[ "$CURRENT" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([a-z]+)\.([0-9]+))?$ ]]; then
CURR_DATE="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
CURR_TYPE="${BASH_REMATCH[5]}"
CURR_NUM="${BASH_REMATCH[6]:-0}"
else
CURR_DATE=""
CURR_TYPE=""
CURR_NUM=0
fi
case "$RELEASE_TYPE" in
alpha)
if [ "$CURR_DATE" = "$TODAY" ] && [ "$CURR_TYPE" = "alpha" ]; then
# Same day, same type - increment prerelease number
NEW_NUM=$((CURR_NUM + 1))
else
# New day or different type - start at 1
NEW_NUM=1
fi
NEW_VERSION="${TODAY}-alpha.${NEW_NUM}"
;;
beta)
if [ "$CURR_DATE" = "$TODAY" ] && [ "$CURR_TYPE" = "beta" ]; then
NEW_NUM=$((CURR_NUM + 1))
else
NEW_NUM=1
fi
NEW_VERSION="${TODAY}-beta.${NEW_NUM}"
;;
stable)
# Stable releases use date; append counter if tag already exists
if git tag -l "v${TODAY}" | grep -q .; then
# Tag exists, find next available counter
COUNTER=1
while git tag -l "v${TODAY}.${COUNTER}" | grep -q .; do
COUNTER=$((COUNTER + 1))
done
NEW_VERSION="${TODAY}.${COUNTER}"
else
NEW_VERSION="${TODAY}"
fi
;;
*)
echo "Unknown release type: $RELEASE_TYPE"
exit 1
;;
esac
echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION"
- name: Update package.json
if: ${{ inputs.should_bump && !inputs.dry_run }}
run: |
NEW_VERSION="${{ steps.bump-version.outputs.new }}"
# Update package.json version
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = '$NEW_VERSION';
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "Updated package.json to version $NEW_VERSION"
- name: Sync extension versions
if: ${{ inputs.should_bump && !inputs.dry_run }}
run: |
# Run plugins:sync if available (aligns extension package versions)
if npm run --silent plugins:sync 2>/dev/null; then
echo "Extension versions synced"
else
echo "plugins:sync not available, skipping"
fi
- name: Commit version bump
if: ${{ inputs.should_bump && !inputs.dry_run }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
NEW_VERSION="${{ steps.bump-version.outputs.new }}"
# Stage all version-related changes
git add package.json
git add extensions/*/package.json 2>/dev/null || true
# Check if there are changes to commit
if git diff --cached --quiet; then
echo "No version changes to commit"
else
git commit -m "chore: bump version to $NEW_VERSION"
git push origin ${{ inputs.source_branch }}
fi
- name: Create tag
if: ${{ inputs.should_bump && !inputs.dry_run }}
run: |
TAG="${{ steps.bump-version.outputs.tag }}"
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"

View File

@@ -3,6 +3,11 @@ name: Workflow Sanity
on:
pull_request:
push:
branches: [main]
concurrency:
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
jobs:
no-tabs:

View File

@@ -24,6 +24,7 @@
"assets/",
"dist/",
"docs/_layouts/",
"extensions/",
"node_modules/",
"patches/",
"pnpm-lock.yaml/",

View File

@@ -10,10 +10,12 @@ Docs: https://docs.openclaw.ai
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
### Added

View File

@@ -25,6 +25,9 @@ Welcome to the lobster tank! 🦞
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
- **Maximilian Nussbaumer** - DevOps, CI/CD
- GitHub: [@quotentiroler](https://github.com/quotentiroler)
## How to Contribute
1. **Bugs & small fixes** → Open a PR!
@@ -76,3 +79,44 @@ We are currently prioritizing:
- **Performance**: Optimizing token usage and compaction logic.
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
## Core vs ClawHub
Not everything belongs in the main repo. Here's how to decide:
| Belongs in **Core** | Belongs on **[ClawHub](https://clawhub.ai)** |
| ---------------------------------------------- | ---------------------------------------------------- |
| Channel integrations (Telegram, Discord, etc.) | Domain-specific skills (QR codes, image tools, etc.) |
| CLI commands and infrastructure | Custom workflows and automations |
| Provider integrations (LLM backends) | Niche or experimental utilities |
| Security, routing, and core plumbing | Third-party service integrations |
**Rule of thumb:** if it adds new dependencies or is useful to some users but not most, it belongs on ClawHub. When in doubt, ask in Discord or open a Discussion before writing code.
Skills submitted as PRs to this repo will be redirected to ClawHub. If the core maintainers later decide certain functionality should be first-party, it will be integrated into core.
## Branch Strategy
We use staged branch promotion to keep `main` stable:
```
dev/* / feature/* / fix/* → develop → alpha → beta → main
```
### For External Contributors
1. Fork the repo
2. Create your branch (`dev/my-feature`, `fix/some-bug`, etc.)
3. Open a PR targeting `develop` (not `main`)
4. CI runs lightweight checks only — no heavy platform tests on your PR
5. Once merged to `develop`, your changes promote through alpha → beta → main automatically
**Do not target `main`** — PRs to `main` will be redirected to `develop`.
### For Maintainers
- **Regular changes**: merge to `develop`, let the pipeline promote
- **Hotfixes**: use `hotfix/*` branches for emergency fixes that bypass staging directly to `main`
- **Docs-only changes**: skip the test pipeline automatically (paths-ignore)
See [Pipeline docs](https://docs.openclaw.ai/reference/pipeline) for full details.

View File

@@ -44,5 +44,5 @@ USER node
#
# For container platforms requiring external health checks:
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
# 2. Override CMD: ["node","dist/index.js","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "dist/index.js", "gateway", "--allow-unconfigured"]
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

View File

@@ -17,6 +17,8 @@ the right time, and can optionally deliver output back to a chat.
If you want _“run this every morning”_ or _“poke the agent in 20 minutes”_,
cron is the mechanism.
Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
## TL;DR
- Cron runs **inside the Gateway** (not inside the model).

View File

@@ -0,0 +1,122 @@
---
summary: "Troubleshoot cron and heartbeat scheduling and delivery"
read_when:
- Cron did not run
- Cron ran but no message was delivered
- Heartbeat seems silent or skipped
title: "Automation Troubleshooting"
---
# Automation troubleshooting
Use this page for scheduler and delivery issues (`cron` + `heartbeat`).
## Command ladder
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then run automation checks:
```bash
openclaw cron status
openclaw cron list
openclaw system heartbeat last
```
## Cron not firing
```bash
openclaw cron status
openclaw cron list
openclaw cron runs --id <jobId> --limit 20
openclaw logs --follow
```
Good output looks like:
- `cron status` reports enabled and a future `nextWakeAtMs`.
- Job is enabled and has a valid schedule/timezone.
- `cron runs` shows `ok` or explicit skip reason.
Common signatures:
- `cron: scheduler disabled; jobs will not run automatically` → cron disabled in config/env.
- `cron: timer tick failed` → scheduler tick crashed; inspect surrounding stack/log context.
- `reason: not-due` in run output → manual run called without `--force` and job not due yet.
## Cron fired but no delivery
```bash
openclaw cron runs --id <jobId> --limit 20
openclaw cron list
openclaw channels status --probe
openclaw logs --follow
```
Good output looks like:
- Run status is `ok`.
- Delivery mode/target are set for isolated jobs.
- Channel probe reports target channel connected.
Common signatures:
- Run succeeded but delivery mode is `none` → no external message is expected.
- Delivery target missing/invalid (`channel`/`to`) → run may succeed internally but skip outbound.
- Channel auth errors (`unauthorized`, `missing_scope`, `Forbidden`) → delivery blocked by channel credentials/permissions.
## Heartbeat suppressed or skipped
```bash
openclaw system heartbeat last
openclaw logs --follow
openclaw config get agents.defaults.heartbeat
openclaw channels status --probe
```
Good output looks like:
- Heartbeat enabled with non-zero interval.
- Last heartbeat result is `ran` (or skip reason is understood).
Common signatures:
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
- `requests-in-flight` → main lane busy; heartbeat deferred.
- `empty-heartbeat-file``HEARTBEAT.md` exists but has no actionable content.
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
## Timezone and activeHours gotchas
```bash
openclaw config get agents.defaults.heartbeat.activeHours
openclaw config get agents.defaults.heartbeat.activeHours.timezone
openclaw config get agents.defaults.userTimezone || echo "agents.defaults.userTimezone not set"
openclaw cron list
openclaw logs --follow
```
Quick rules:
- `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set).
- Cron without `--tz` uses gateway host timezone.
- Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz).
- ISO timestamps without timezone are treated as UTC for cron `at` schedules.
Common signatures:
- Jobs run at the wrong wall-clock time after host timezone changes.
- Heartbeat always skipped during your daytime because `activeHours.timezone` is wrong.
Related:
- [/automation/cron-jobs](/automation/cron-jobs)
- [/gateway/heartbeat](/gateway/heartbeat)
- [/automation/cron-vs-heartbeat](/automation/cron-vs-heartbeat)
- [/concepts/timezone](/concepts/timezone)

View File

@@ -202,6 +202,32 @@ Once verified, the bot can decrypt messages in encrypted rooms.
| Location | ✅ Supported (geo URI; altitude ignored) |
| Native commands | ✅ Supported |
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list matrix
```
Common failures:
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
- Encrypted rooms fail: crypto support or encryption settings mismatch.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Configuration reference (Matrix)
Full configuration: [Configuration](/gateway/configuration)

View File

@@ -168,6 +168,32 @@ Config:
- Groups: `signal:group:<groupId>`.
- Usernames: `username:<name>` (if supported by your Signal account).
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list signal
```
Common failures:
- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode.
- DMs ignored: sender is pending pairing approval.
- Group messages ignored: group sender/mention gating blocks delivery.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Configuration reference (Signal)
Full configuration: [Configuration](/gateway/configuration)

View File

@@ -537,6 +537,32 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
`files:write`) or those operations will fail.
## Troubleshooting
Run this ladder first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
```bash
openclaw pairing list slack
```
Common failures:
- Connected but no channel replies: channel blocked by `groupPolicy` or not in `channels.slack.channels` allowlist.
- DMs ignored: sender not approved when `channels.slack.dm.policy="pairing"`.
- API errors (`missing_scope`, `not_in_channel`, auth failures): bot/app tokens or Slack scopes are incomplete.
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
## Notes
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.

View File

@@ -1,30 +1,116 @@
---
summary: "Channel-specific troubleshooting shortcuts (Discord/Telegram/WhatsApp/iMessage)"
summary: "Fast channel level troubleshooting with per channel failure signatures and fixes"
read_when:
- A channel connects but messages dont flow
- Investigating channel misconfiguration (intents, permissions, privacy mode)
- Channel transport says connected but replies fail
- You need channel specific checks before deep provider docs
title: "Channel Troubleshooting"
---
# Channel troubleshooting
Start with:
Use this page when a channel connects but behavior is wrong.
## Command ladder
Run these in order first:
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
`channels status --probe` prints warnings when it can detect common channel misconfigurations, and includes small live checks (credentials, some permissions/membership).
Healthy baseline:
## Channels
- `Runtime: running`
- `RPC probe: ok`
- Channel probe shows connected/ready
- Discord: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
- Telegram: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
- WhatsApp: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
- iMessage (legacy): [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc)
## WhatsApp
## Telegram quick fixes
### WhatsApp failure signatures
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
| Symptom | Fastest check | Fix |
| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. |
| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. |
| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. |
Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
## Telegram
### 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`. |
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
## Discord
### Discord failure signatures
| Symptom | Fastest check | Fix |
| ------------------------------- | ----------------------------------- | --------------------------------------------------------- |
| Bot online but no guild replies | `openclaw channels status --probe` | Allow guild/channel and verify message content intent. |
| Group messages ignored | Check logs for mention gating drops | Mention bot or set guild/channel `requireMention: false`. |
| DM replies missing | `openclaw pairing list discord` | Approve DM pairing or adjust DM policy. |
Full troubleshooting: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
## Slack
### Slack failure signatures
| Symptom | Fastest check | Fix |
| -------------------------------------- | ----------------------------------------- | ------------------------------------------------- |
| Socket mode connected but no responses | `openclaw channels status --probe` | Verify app token + bot token and required scopes. |
| DMs blocked | `openclaw pairing list slack` | Approve pairing or relax DM policy. |
| Channel message ignored | Check `groupPolicy` and channel allowlist | Allow the channel or switch policy to `open`. |
Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubleshooting)
## iMessage and BlueBubbles
### iMessage and BlueBubbles failure signatures
| Symptom | Fastest check | Fix |
| -------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- |
| No inbound events | Verify webhook/server reachability and app permissions | Fix webhook URL or BlueBubbles server state. |
| Can send but no receive on macOS | Check macOS privacy permissions for Messages automation | Re-grant TCC permissions and restart channel process. |
| DM sender blocked | `openclaw pairing list imessage` or `openclaw pairing list bluebubbles` | Approve pairing or update allowlist. |
Full troubleshooting:
- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc)
- [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting)
## Signal
### Signal failure signatures
| Symptom | Fastest check | Fix |
| ------------------------------- | ------------------------------------------ | -------------------------------------------------------- |
| Daemon reachable but bot silent | `openclaw channels status --probe` | Verify `signal-cli` daemon URL/account and receive mode. |
| DM blocked | `openclaw pairing list signal` | Approve sender or adjust DM policy. |
| Group replies do not trigger | Check group allowlist and mention patterns | Add sender/group or loosen gating. |
Full troubleshooting: [/channels/signal#troubleshooting](/channels/signal#troubleshooting)
## Matrix
### Matrix failure signatures
| Symptom | Fastest check | Fix |
| ----------------------------------- | -------------------------------------------- | ----------------------------------------------- |
| Logged in but ignores room messages | `openclaw channels status --probe` | Check `groupPolicy` and room allowlist. |
| DMs do not process | `openclaw pairing list matrix` | Approve sender or adjust DM policy. |
| Encrypted rooms fail | Verify crypto module and encryption settings | Enable encryption support and rejoin/sync room. |
Full troubleshooting: [/channels/matrix#troubleshooting](/channels/matrix#troubleshooting)

View File

@@ -98,6 +98,10 @@
"source": "/opencode",
"destination": "/providers/opencode"
},
{
"source": "/qianfan",
"destination": "/providers/qianfan"
},
{
"source": "/mattermost",
"destination": "/channels/mattermost"
@@ -660,7 +664,7 @@
},
{
"source": "/troubleshooting",
"destination": "/gateway/troubleshooting"
"destination": "/help/troubleshooting"
},
{
"source": "/web/tui",
@@ -962,6 +966,7 @@
"hooks/soul-evil",
"automation/cron-jobs",
"automation/cron-vs-heartbeat",
"automation/troubleshooting",
"automation/webhook",
"automation/gmail-pubsub",
"automation/poll",
@@ -972,6 +977,7 @@
"group": "Media and devices",
"pages": [
"nodes/index",
"nodes/troubleshooting",
"nodes/images",
"nodes/audio",
"nodes/camera",
@@ -1006,7 +1012,8 @@
"providers/opencode",
"providers/glm",
"providers/zai",
"providers/synthetic"
"providers/synthetic",
"providers/qianfan"
]
}
]

View File

@@ -1453,7 +1453,7 @@ working directory). The path must exist to be used.
### `agents.defaults.skipBootstrap`
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, and `BOOTSTRAP.md`).
Use this for pre-seeded deployments where your workspace files come from a repo.

View File

@@ -13,6 +13,8 @@ title: "Heartbeat"
Heartbeat runs **periodic agent turns** in the main session so the model can
surface anything that needs attention without spamming you.
Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
## Quick start (beginner)
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence.

File diff suppressed because it is too large Load Diff

View File

@@ -1,98 +1,265 @@
---
summary: "Troubleshooting hub: symptoms → checks → fixes"
summary: "Symptom first troubleshooting hub for OpenClaw"
read_when:
- You see an error and want the fix path
- The installer says “success” but the CLI doesnt work
- OpenClaw is not working and you need the fastest path to a fix
- You want a triage flow before diving into deep runbooks
title: "Troubleshooting"
---
# Troubleshooting
If you only have 2 minutes, use this page as a triage front door.
## First 60 seconds
Run these in order:
Run this exact ladder in order:
```bash
openclaw status
openclaw status --all
openclaw gateway probe
openclaw logs --follow
openclaw gateway status
openclaw doctor
openclaw channels status --probe
openclaw logs --follow
```
If the gateway is reachable, deep probes:
Good output in one line:
```bash
openclaw status --deep
- `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 status``Runtime: running` and `RPC probe: ok`.
- `openclaw doctor` → no blocking config/service errors.
- `openclaw channels status --probe` → channels report `connected` or `ready`.
- `openclaw logs --follow` → steady activity, no repeating fatal errors.
## Decision tree
```mermaid
flowchart TD
A[OpenClaw is not working] --> B{What breaks first}
B --> C[No replies]
B --> D[Dashboard or Control UI will not connect]
B --> E[Gateway will not start or service not running]
B --> F[Channel connects but messages do not flow]
B --> G[Cron or heartbeat did not fire or did not deliver]
B --> H[Node is paired but camera canvas screen exec fails]
B --> I[Browser tool fails]
C --> C1[/No replies section/]
D --> D1[/Control UI section/]
E --> E1[/Gateway section/]
F --> F1[/Channel flow section/]
G --> G1[/Automation section/]
H --> H1[/Node tools section/]
I --> I1[/Browser section/]
```
## Common “it broke” cases
<AccordionGroup>
<Accordion title="No replies">
```bash
openclaw status
openclaw gateway status
openclaw channels status --probe
openclaw pairing list <channel>
openclaw logs --follow
```
### `openclaw: command not found`
Good output looks like:
Almost always a Node/npm PATH issue. Start here:
- `Runtime: running`
- `RPC probe: ok`
- Your channel shows connected/ready in `channels status --probe`
- Sender appears approved (or DM policy is open/allowlist)
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
Common log signatures:
### Installer fails (or you need full logs)
- `drop guild message (mention required` → mention gating blocked the message in Discord.
- `pairing request` → sender is unapproved and waiting for DM pairing approval.
- `blocked` / `allowlist` in channel logs → sender, room, or group is filtered.
Re-run the installer in verbose mode to see the full trace and npm output:
Deep pages:
```bash
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --verbose
```
- [/gateway/troubleshooting#no-replies](/gateway/troubleshooting#no-replies)
- [/channels/troubleshooting](/channels/troubleshooting)
- [/start/pairing](/start/pairing)
For beta installs:
</Accordion>
```bash
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --beta --verbose
```
<Accordion title="Dashboard or Control UI will not connect">
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
You can also set `OPENCLAW_VERBOSE=1` instead of the flag.
Good output looks like:
### Gateway “unauthorized”, cant connect, or keeps reconnecting
- `Dashboard: http://...` is shown in `openclaw gateway status`
- `RPC probe: ok`
- No auth loop in logs
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Gateway authentication](/gateway/authentication)
Common log signatures:
### Control UI fails on HTTP (device identity required)
- `device identity required` → HTTP/non-secure context cannot complete device auth.
- `unauthorized` / reconnect loop → wrong token/password or auth mode mismatch.
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http)
Deep pages:
### `docs.openclaw.ai` shows an SSL error (Comcast/Xfinity)
- [/gateway/troubleshooting#dashboard-control-ui-connectivity](/gateway/troubleshooting#dashboard-control-ui-connectivity)
- [/web/control-ui](/web/control-ui)
- [/gateway/authentication](/gateway/authentication)
Some Comcast/Xfinity connections block `docs.openclaw.ai` via Xfinity Advanced Security.
Disable Advanced Security or add `docs.openclaw.ai` to the allowlist, then retry.
</Accordion>
- Xfinity Advanced Security help: [https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security](https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security)
- Quick sanity checks: try a mobile hotspot or VPN to confirm its ISP-level filtering
<Accordion title="Gateway will not start or service installed but not running">
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
### Service says running, but RPC probe fails
Good output looks like:
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Background process / service](/gateway/background-process)
- `Service: ... (loaded)`
- `Runtime: running`
- `RPC probe: ok`
### Model/auth failures (rate limit, billing, “all models failed”)
Common log signatures:
- [Models](/cli/models)
- [OAuth / auth concepts](/concepts/oauth)
- `Gateway start blocked: set gateway.mode=local` → gateway mode is unset/remote.
- `refusing to bind gateway ... without auth` → non-loopback bind without token/password.
- `another gateway instance is already listening` or `EADDRINUSE` → port already taken.
### `/model` says `model not allowed`
Deep pages:
This usually means `agents.defaults.models` is configured as an allowlist. When its non-empty,
only those provider/model keys can be selected.
- [/gateway/troubleshooting#gateway-service-not-running](/gateway/troubleshooting#gateway-service-not-running)
- [/gateway/background-process](/gateway/background-process)
- [/gateway/configuration](/gateway/configuration)
- Check the allowlist: `openclaw config get agents.defaults.models`
- Add the model you want (or clear the allowlist) and retry `/model`
- Use `/models` to browse the allowed providers/models
</Accordion>
### When filing an issue
<Accordion title="Channel connects but messages do not flow">
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Paste a safe report:
Good output looks like:
```bash
openclaw status --all
```
- Channel transport is connected.
- Pairing/allowlist checks pass.
- Mentions are detected where required.
If you can, include the relevant log tail from `openclaw logs --follow`.
Common log signatures:
- `mention required` → group mention gating blocked processing.
- `pairing` / `pending` → DM sender is not approved yet.
- `not_in_channel`, `missing_scope`, `Forbidden`, `401/403` → channel permission token issue.
Deep pages:
- [/gateway/troubleshooting#channel-connected-messages-not-flowing](/gateway/troubleshooting#channel-connected-messages-not-flowing)
- [/channels/troubleshooting](/channels/troubleshooting)
</Accordion>
<Accordion title="Cron or heartbeat did not fire or did not deliver">
```bash
openclaw status
openclaw gateway status
openclaw cron status
openclaw cron list
openclaw cron runs --id <jobId> --limit 20
openclaw logs --follow
```
Good output looks like:
- `cron.status` shows enabled with a next wake.
- `cron runs` shows recent `ok` entries.
- Heartbeat is enabled and not outside active hours.
Common log signatures:
- `cron: scheduler disabled; jobs will not run automatically` → cron is disabled.
- `heartbeat skipped` with `reason=quiet-hours` → outside configured active hours.
- `requests-in-flight` → main lane busy; heartbeat wake was deferred.
- `unknown accountId` → heartbeat delivery target account does not exist.
Deep pages:
- [/gateway/troubleshooting#cron-and-heartbeat-delivery](/gateway/troubleshooting#cron-and-heartbeat-delivery)
- [/automation/troubleshooting](/automation/troubleshooting)
- [/gateway/heartbeat](/gateway/heartbeat)
</Accordion>
<Accordion title="Node is paired but tool fails camera canvas screen exec">
```bash
openclaw status
openclaw gateway status
openclaw nodes status
openclaw nodes describe --node <idOrNameOrIp>
openclaw logs --follow
```
Good output looks like:
- Node is listed as connected and paired for role `node`.
- Capability exists for the command you are invoking.
- Permission state is granted for the tool.
Common log signatures:
- `NODE_BACKGROUND_UNAVAILABLE` → bring node app to foreground.
- `*_PERMISSION_REQUIRED` → OS permission was denied/missing.
- `SYSTEM_RUN_DENIED: approval required` → exec approval is pending.
- `SYSTEM_RUN_DENIED: allowlist miss` → command not on exec allowlist.
Deep pages:
- [/gateway/troubleshooting#node-paired-tool-fails](/gateway/troubleshooting#node-paired-tool-fails)
- [/nodes/troubleshooting](/nodes/troubleshooting)
- [/tools/exec-approvals](/tools/exec-approvals)
</Accordion>
<Accordion title="Browser tool fails">
```bash
openclaw status
openclaw gateway status
openclaw browser status
openclaw logs --follow
openclaw doctor
```
Good output looks like:
- Browser status shows `running: true` and a chosen browser/profile.
- `openclaw` profile starts or `chrome` relay has an attached tab.
Common log signatures:
- `Failed to start Chrome CDP on port` → local browser launch failed.
- `browser.executablePath not found` → configured binary path is wrong.
- `Chrome extension relay is running, but no tab is connected` → extension not attached.
- `Browser attachOnly is enabled ... not reachable` → attach-only profile has no live CDP target.
Deep pages:
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
- [/tools/chrome-extension](/tools/chrome-extension)
</Accordion>
</AccordionGroup>

View File

@@ -19,6 +19,7 @@ Notes:
- Nodes are **peripherals**, not gateways. They dont run the gateway service.
- Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes.
- Troubleshooting runbook: [/nodes/troubleshooting](/nodes/troubleshooting)
## Pairing + status

View File

@@ -0,0 +1,112 @@
---
summary: "Troubleshoot node pairing, foreground requirements, permissions, and tool failures"
read_when:
- Node is connected but camera/canvas/screen/exec tools fail
- You need the node pairing versus approvals mental model
title: "Node Troubleshooting"
---
# Node troubleshooting
Use this page when a node is visible in status but node tools fail.
## Command ladder
```bash
openclaw status
openclaw gateway status
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then run node specific checks:
```bash
openclaw nodes status
openclaw nodes describe --node <idOrNameOrIp>
openclaw approvals get --node <idOrNameOrIp>
```
Healthy signals:
- Node is connected and paired for role `node`.
- `nodes describe` includes the capability you are calling.
- Exec approvals show expected mode/allowlist.
## Foreground requirements
`canvas.*`, `camera.*`, and `screen.*` are foreground only on iOS/Android nodes.
Quick check and fix:
```bash
openclaw nodes describe --node <idOrNameOrIp>
openclaw nodes canvas snapshot --node <idOrNameOrIp>
openclaw logs --follow
```
If you see `NODE_BACKGROUND_UNAVAILABLE`, bring the node app to the foreground and retry.
## Permissions matrix
| Capability | iOS | Android | macOS node app | Typical failure code |
| ---------------------------- | --------------------------------------- | -------------------------------------------- | ----------------------------- | ------------------------------ |
| `camera.snap`, `camera.clip` | Camera (+ mic for clip audio) | Camera (+ mic for clip audio) | Camera (+ mic for clip audio) | `*_PERMISSION_REQUIRED` |
| `screen.record` | Screen Recording (+ mic optional) | Screen capture prompt (+ mic optional) | Screen Recording | `*_PERMISSION_REQUIRED` |
| `location.get` | While Using or Always (depends on mode) | Foreground/Background location based on mode | Location permission | `LOCATION_PERMISSION_REQUIRED` |
| `system.run` | n/a (node host path) | n/a (node host path) | Exec approvals required | `SYSTEM_RUN_DENIED` |
## Pairing versus approvals
These are different gates:
1. **Device pairing**: can this node connect to the gateway?
2. **Exec approvals**: can this node run a specific shell command?
Quick checks:
```bash
openclaw devices list
openclaw nodes status
openclaw approvals get --node <idOrNameOrIp>
openclaw approvals allowlist add --node <idOrNameOrIp> "/usr/bin/uname"
```
If pairing is missing, approve the node device first.
If pairing is fine but `system.run` fails, fix exec approvals/allowlist.
## Common node error codes
- `NODE_BACKGROUND_UNAVAILABLE` → app is backgrounded; bring it foreground.
- `CAMERA_DISABLED` → camera toggle disabled in node settings.
- `*_PERMISSION_REQUIRED` → OS permission missing/denied.
- `LOCATION_DISABLED` → location mode is off.
- `LOCATION_PERMISSION_REQUIRED` → requested location mode not granted.
- `LOCATION_BACKGROUND_UNAVAILABLE` → app is backgrounded but only While Using permission exists.
- `SYSTEM_RUN_DENIED: approval required` → exec request needs explicit approval.
- `SYSTEM_RUN_DENIED: allowlist miss` → command blocked by allowlist mode.
## Fast recovery loop
```bash
openclaw nodes status
openclaw nodes describe --node <idOrNameOrIp>
openclaw approvals get --node <idOrNameOrIp>
openclaw logs --follow
```
If still stuck:
- Re-approve device pairing.
- Re-open node app (foreground).
- Re-grant OS permissions.
- Recreate/adjust exec approval policy.
Related:
- [/nodes/index](/nodes/index)
- [/nodes/camera](/nodes/camera)
- [/nodes/location-command](/nodes/location-command)
- [/tools/exec-approvals](/tools/exec-approvals)
- [/gateway/pairing](/gateway/pairing)

View File

@@ -50,6 +50,7 @@ See [Venice AI](/providers/venice).
- [MiniMax](/providers/minimax)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [Ollama (local models)](/providers/ollama)
- [Qianfan](/providers/qianfan)
## Transcription providers

View File

@@ -46,6 +46,7 @@ See [Venice AI](/providers/venice).
- [MiniMax](/providers/minimax)
- [Venice (Venice AI)](/providers/venice)
- [Amazon Bedrock](/bedrock)
- [Qianfan](/providers/qianfan)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

38
docs/providers/qianfan.md Normal file
View File

@@ -0,0 +1,38 @@
---
summary: "Use Qianfan's unified API to access many models in OpenClaw"
read_when:
- You want a single API key for many LLMs
- You need Baidu Qianfan setup guidance
title: "Qianfan"
---
# Qianfan Provider Guide
Qianfan is Baidu's MaaS platform, provides a **unified API** that routes requests to many models behind a single
endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switching the base URL.
## Prerequisites
1. A Baidu Cloud account with Qianfan API access
2. An API key from the Qianfan console
3. OpenClaw installed on your system
## Getting Your API Key
1. Visit the [Qianfan Console](https://console.bce.baidu.com/qianfan/ais/console/apiKey)
2. Create a new application or select an existing one
3. Generate an API key (format: `bce-v3/ALTAK-...`)
4. Copy the API key for use with OpenClaw
## CLI setup
```bash
openclaw onboard --auth-choice qianfan-api-key
```
## Related Documentation
- [OpenClaw Configuration](/configuration)
- [Model Providers](/concepts/model-providers)
- [Agent Setup](/agents)
- [Qianfan API Documentation](https://cloud.baidu.com/doc/qianfan-api/s/3m7of64lb)

131
docs/reference/pipeline.md Normal file
View File

@@ -0,0 +1,131 @@
# Release Pipeline
This document describes openclaw's staged release pipeline for contributors and maintainers.
## Branch Strategy
```
dev/* ──────► develop ──────► alpha ──────► beta ──────► main
feature/* │ │ │ │
fix/* │ │ │ │
▼ ▼ ▼ ▼
Internal Alpha Beta Stable
testing testers testers release
```
### Branch Purposes
| Branch | Purpose | npm tag | Who uses it |
| ----------------------------- | ------------------- | --------- | ---------------- |
| `dev/*`, `feature/*`, `fix/*` | Active development | - | Contributors |
| `develop` | Integration branch | - | CI validation |
| `alpha` | Early testing | `@alpha` | Internal testers |
| `beta` | Pre-release testing | `@beta` | Beta testers |
| `main` | Production releases | `@latest` | Everyone |
## Workflow Overview
### 1. Feature Development
1. Create a branch: `git checkout -b dev/my-feature`
2. Make changes and push
3. **Auto-PR created** to `develop` via `feature-pr.yml`
4. Get review, iterate, merge to `develop`
### 2. Promotion Through Stages
When code lands in `develop`, the `promote-branch.yml` workflow:
1. Runs tests appropriate for that stage
2. Creates a PR to the next branch (develop → alpha → beta → main)
3. Auto-merges `develop → alpha` if tests pass
4. Requires manual approval for `alpha → beta` and `beta → main`
### 3. Releases
Releases are triggered manually via the **Release** workflow:
1. Go to Actions → Release → Run workflow
2. Select release type: `alpha`, `beta`, or `stable`
3. Workflow runs: version bump → changelog → tests → npm publish → Docker push
## Test Coverage by Stage
| Stage | Tests Run |
| ------- | ----------------------------------------------------- |
| develop | tsgo, lint, format, protocol, unit tests (Node + Bun) |
| alpha | + secrets scan |
| beta | + Windows tests |
| stable | + macOS tests, install smoke tests |
## Emergency Hotfixes
For critical production issues:
1. Create branch: `git checkout -b hotfix/critical-bug`
2. Push → **Auto-PR created** directly to `main`
3. Get expedited review (skip staging)
4. After merge, cherry-pick to `develop`, `alpha`, `beta` to sync
```bash
# After hotfix merges to main
git checkout develop && git cherry-pick <commit-sha> && git push
git checkout alpha && git cherry-pick <commit-sha> && git push
git checkout beta && git cherry-pick <commit-sha> && git push
```
## npm Installation by Channel
```bash
# Stable (default)
npm install -g openclaw
# Beta testing
npm install -g openclaw@beta
# Alpha testing (bleeding edge)
npm install -g openclaw@alpha
```
## Docker Images
Images are published to GitHub Container Registry:
```bash
# Stable
docker pull ghcr.io/openclaw/openclaw:latest
# Beta
docker pull ghcr.io/openclaw/openclaw:beta
# Specific version
docker pull ghcr.io/openclaw/openclaw:2026.2.6
```
## Version Format
- **Stable**: `YYYY.M.D` (e.g., `2026.2.6`)
- **Beta**: `YYYY.M.D-beta.N` (e.g., `2026.2.6-beta.1`)
- **Alpha**: `YYYY.M.D-alpha.N` (e.g., `2026.2.6-alpha.3`)
## Setup
### Required Secrets
Configure these in GitHub repo Settings → Secrets and variables → Actions:
| Secret | Required? | Purpose | How to get it |
| --------------------- | -------------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
| `GITHUB_TOKEN` | Automatic | PR creation, Docker registry, branch ops | Provided by GitHub Actions — no setup needed |
| `NPM_TOKEN` | Yes (for publishing) | npm publish with `@alpha`, `@beta`, `@latest` tags | npmjs.com → Access Tokens → Generate New Token → Automation |
| `DISCORD_WEBHOOK_URL` | Optional | Notifications for promotions, test results, deployments | Discord → Server Settings → Integrations → Webhooks |
Without `NPM_TOKEN`, the pipeline runs normally but skips npm publishing. Without `DISCORD_WEBHOOK_URL`, notifications are silently skipped.
### Branch Setup
Staging branches are auto-created from `main` when the first promotion runs. No manual setup required.
### Rollback
The rollback workflow (`Actions → Rollback`) re-tags npm and Docker to a previous version. Requires `NPM_TOKEN` and is manual-trigger only.

View File

@@ -80,7 +80,7 @@ When onboarding finishes, we auto-open the dashboard and print a clean (non-toke
OpenClaw reads operating instructions and “memory” from its workspace directory.
By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it). `MEMORY.md` is optional (not auto-created); when present, it is loaded for normal sessions. Subagent sessions only inject `AGENTS.md` and `TOOLS.md`.
Tip: treat this folder like OpenClaws “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. If git is installed, brand-new workspaces are auto-initialized.

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -26,7 +26,9 @@ const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
function sanitizeFilename(input: string | undefined, fallback: string): string {
const trimmed = input?.trim() ?? "";
const base = trimmed ? path.basename(trimmed) : "";
return base || fallback;
const name = base || fallback;
// Strip characters that could enable multipart header injection (CWE-93)
return name.replace(/[\r\n"\\]/g, "_");
}
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import path from "node:path";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
@@ -336,10 +337,13 @@ export async function setGroupIconBlueBubbles(
const parts: Uint8Array[] = [];
const encoder = new TextEncoder();
// Sanitize filename to prevent multipart header injection (CWE-93)
const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png";
// Add file field named "icon" as per API spec
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`),
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`),
);
parts.push(
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),

View File

@@ -393,6 +393,48 @@ describe("BlueBubbles webhook monitor", () => {
expect(res.statusCode).toBe(400);
});
it("returns 400 when request body times out (Slow-Loris protection)", async () => {
vi.useFakeTimers();
try {
const account = createMockAccount();
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
// Create a request that never sends data or ends (simulates slow-loris)
const req = new EventEmitter() as IncomingMessage;
req.method = "POST";
req.url = "/bluebubbles-webhook";
req.headers = {};
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
req.destroy = vi.fn();
const res = createMockResponse();
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
// Advance past the 30s timeout
await vi.advanceTimersByTimeAsync(31_000);
const handled = await handledPromise;
expect(handled).toBe(true);
expect(res.statusCode).toBe(400);
expect(req.destroy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("authenticates via password query parameter", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};

View File

@@ -508,14 +508,29 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
};
}
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
const chunks: Buffer[] = [];
let total = 0;
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
let done = false;
const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
if (done) {
return;
}
done = true;
clearTimeout(timer);
resolve(result);
};
const timer = setTimeout(() => {
finish({ ok: false, error: "request body timeout" });
req.destroy();
}, timeoutMs);
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
resolve({ ok: false, error: "payload too large" });
finish({ ok: false, error: "payload too large" });
req.destroy();
return;
}
@@ -525,27 +540,30 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
try {
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
resolve({ ok: false, error: "empty payload" });
finish({ ok: false, error: "empty payload" });
return;
}
try {
resolve({ ok: true, value: JSON.parse(raw) as unknown });
finish({ ok: true, value: JSON.parse(raw) as unknown });
return;
} catch {
const params = new URLSearchParams(raw);
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
if (payload) {
resolve({ ok: true, value: JSON.parse(payload) as unknown });
finish({ ok: true, value: JSON.parse(payload) as unknown });
return;
}
throw new Error("invalid json");
}
} catch (err) {
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
});
req.on("error", (err) => {
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
});
req.on("close", () => {
finish({ ok: false, error: "connection closed" });
});
});
}

View File

@@ -16,7 +16,9 @@ export type BlueBubblesServerInfo = {
computer_id?: string;
};
/** Cache server info by account ID to avoid repeated API calls */
/** Cache server info by account ID to avoid repeated API calls.
* Size-capped to prevent unbounded growth (#4948). */
const MAX_SERVER_INFO_CACHE_SIZE = 64;
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
@@ -56,6 +58,13 @@ export async function fetchBlueBubblesServerInfo(params: {
const data = payload?.data as BlueBubblesServerInfo | undefined;
if (data) {
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
// Evict oldest entries if cache exceeds max size
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
const oldest = serverInfoCache.keys().next().value;
if (oldest !== undefined) {
serverInfoCache.delete(oldest);
}
}
}
return data ?? null;
} catch {

View File

@@ -370,6 +370,16 @@ describe("send", () => {
).rejects.toThrow("requires text");
});
it("throws when text becomes empty after markdown stripping", async () => {
// Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown
await expect(
sendMessageBlueBubbles("+15551234567", "***", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("empty after markdown removal");
});
it("throws when serverUrl is missing", async () => {
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
"serverUrl is required",
@@ -438,6 +448,77 @@ describe("send", () => {
expect(body.method).toBeUndefined();
});
it("strips markdown formatting from outbound messages", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-stripped" },
}),
),
});
const result = await sendMessageBlueBubbles(
"+15551234567",
"**Bold** and *italic* with `code`\n## Header",
{
serverUrl: "http://localhost:1234",
password: "test",
},
);
expect(result.messageId).toBe("msg-uuid-stripped");
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
// Markdown should be stripped: no asterisks, backticks, or hashes
expect(body.message).toBe("Bold and italic with code\nHeader");
});
it("strips markdown when creating a new chat", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-stripped" },
}),
),
});
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-stripped");
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
// Markdown should be stripped
expect(body.message).toBe("Welcome to the chat!");
});
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import crypto from "node:crypto";
import { stripMarkdown } from "openclaw/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
extractHandleFromChatGuid,
@@ -332,6 +333,7 @@ async function createNewChatWithMessage(params: {
const payload = {
addresses: [params.address],
message: params.message,
tempGuid: `temp-${crypto.randomUUID()}`,
};
const res = await blueBubblesFetchWithTimeout(
url,
@@ -377,6 +379,11 @@ export async function sendMessageBlueBubbles(
if (!trimmedText.trim()) {
throw new Error("BlueBubbles send requires text");
}
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
const strippedText = stripMarkdown(trimmedText);
if (!strippedText.trim()) {
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
}
const account = resolveBlueBubblesAccount({
cfg: opts.cfg ?? {},
@@ -406,7 +413,7 @@ export async function sendMessageBlueBubbles(
baseUrl,
password,
address: target.address,
message: trimmedText,
message: strippedText,
timeoutMs: opts.timeoutMs,
});
}
@@ -419,7 +426,7 @@ export async function sendMessageBlueBubbles(
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: trimmedText,
message: strippedText,
};
if (needsPrivateApi) {
payload.method = "private-api";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Google Chat channel plugin",
"type": "module",
"dependencies": {

View File

@@ -8,6 +8,8 @@ const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceacc
const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
const MAX_AUTH_CACHE_SIZE = 32;
const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
const verifyClient = new OAuth2Client();
@@ -30,20 +32,32 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
return cached.auth;
}
const evictOldest = () => {
if (authCache.size > MAX_AUTH_CACHE_SIZE) {
const oldest = authCache.keys().next().value;
if (oldest !== undefined) {
authCache.delete(oldest);
}
}
};
if (account.credentialsFile) {
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
authCache.set(account.accountId, { key, auth });
evictOldest();
return auth;
}
if (account.credentials) {
const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] });
authCache.set(account.accountId, { key, auth });
evictOldest();
return auth;
}
const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] });
authCache.set(account.accountId, { key, auth });
evictOldest();
return auth;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw iMessage channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw LINE channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@@ -17,7 +17,18 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
return trimmed ? trimmed : null;
}
// Size-capped to prevent unbounded growth (#4948)
const MAX_DIRECT_ROOM_CACHE_SIZE = 1024;
const directRoomCache = new Map<string, string>();
function setDirectRoomCached(key: string, value: string): void {
directRoomCache.set(key, value);
if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) {
const oldest = directRoomCache.keys().next().value;
if (oldest !== undefined) {
directRoomCache.delete(oldest);
}
}
}
async function persistDirectRoom(
client: MatrixClient,
@@ -62,7 +73,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
const directContent = await client.getAccountData(EventType.Direct);
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
if (list.length > 0) {
directRoomCache.set(trimmed, list[0]);
setDirectRoomCached(trimmed, list[0]);
return list[0];
}
} catch {
@@ -86,7 +97,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
}
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
directRoomCache.set(trimmed, roomId);
setDirectRoomCached(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
@@ -99,7 +110,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
}
if (fallbackRoom) {
directRoomCache.set(trimmed, fallbackRoom);
setDirectRoomCached(trimmed, fallbackRoom);
await persistDirectRoom(client, trimmed, fallbackRoom);
return fallbackRoom;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw core memory search plugin",
"type": "module",
"devDependencies": {

View File

@@ -6,8 +6,8 @@
* Provides seamless auto-recall and auto-capture via lifecycle hooks.
*/
import type * as LanceDB from "@lancedb/lancedb";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import * as lancedb from "@lancedb/lancedb";
import { Type } from "@sinclair/typebox";
import { randomUUID } from "node:crypto";
import OpenAI from "openai";
@@ -23,6 +23,19 @@ import {
// Types
// ============================================================================
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
if (!lancedbImportPromise) {
lancedbImportPromise = import("@lancedb/lancedb");
}
try {
return await lancedbImportPromise;
} catch (err) {
// Common on macOS today: upstream package may not ship darwin native bindings.
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`);
}
};
type MemoryEntry = {
id: string;
text: string;
@@ -44,8 +57,8 @@ type MemorySearchResult = {
const TABLE_NAME = "memories";
class MemoryDB {
private db: lancedb.Connection | null = null;
private table: lancedb.Table | null = null;
private db: LanceDB.Connection | null = null;
private table: LanceDB.Table | null = null;
private initPromise: Promise<void> | null = null;
constructor(
@@ -66,6 +79,7 @@ class MemoryDB {
}
private async doInitialize(): Promise<void> {
const lancedb = await loadLanceDB();
this.db = await lancedb.connect(this.dbPath);
const tables = await this.db.tableNames();

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@@ -229,31 +229,58 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.end(JSON.stringify(body));
}
async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise<unknown> {
async function readJsonBody(
req: IncomingMessage,
maxBytes = 64 * 1024,
timeoutMs = 30_000,
): Promise<unknown> {
return new Promise((resolve, reject) => {
let done = false;
const finish = (fn: () => void) => {
if (done) {
return;
}
done = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
finish(() => {
const err = new Error("Request body timeout");
req.destroy(err);
reject(err);
});
}, timeoutMs);
const chunks: Buffer[] = [];
let totalBytes = 0;
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
reject(new Error("Request body too large"));
req.destroy();
finish(() => {
reject(new Error("Request body too large"));
req.destroy();
});
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const body = Buffer.concat(chunks).toString("utf-8");
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Invalid JSON"));
}
finish(() => {
try {
const body = Buffer.concat(chunks).toString("utf-8");
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Invalid JSON"));
}
});
});
req.on("error", reject);
req.on("error", (err) => finish(() => reject(err)));
req.on("close", () => finish(() => reject(new Error("Connection closed"))));
});
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Signal channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Slack channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Telegram channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Twitch channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@@ -296,23 +296,48 @@ export class VoiceCallWebhookServer {
}
/**
* Read request body as string.
* Read request body as string with timeout protection.
*/
private readBody(req: http.IncomingMessage, maxBytes: number): Promise<string> {
private readBody(
req: http.IncomingMessage,
maxBytes: number,
timeoutMs = 30_000,
): Promise<string> {
return new Promise((resolve, reject) => {
let done = false;
const finish = (fn: () => void) => {
if (done) {
return;
}
done = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
finish(() => {
const err = new Error("Request body timeout");
req.destroy(err);
reject(err);
});
}, timeoutMs);
const chunks: Buffer[] = [];
let totalBytes = 0;
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
req.destroy();
reject(new Error("PayloadTooLarge"));
finish(() => {
req.destroy();
reject(new Error("PayloadTooLarge"));
});
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
req.on("end", () => finish(() => resolve(Buffer.concat(chunks).toString("utf-8"))));
req.on("error", (err) => finish(() => reject(err)));
req.on("close", () => finish(() => reject(new Error("Connection closed"))));
});
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026.2.6-3
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-2
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.2.6",
"version": "2026.2.6-3",
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
"type": "module",
"dependencies": {

View File

@@ -11,4 +11,23 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
}
}
await import("./dist/entry.js");
const tryImport = async (specifier) => {
try {
await import(specifier);
return true;
} catch (err) {
// Only swallow missing-module errors; rethrow real runtime errors.
if (err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND") {
return false;
}
throw err;
}
};
if (await tryImport("./dist/entry.js")) {
// OK
} else if (await tryImport("./dist/entry.mjs")) {
// OK
} else {
throw new Error("openclaw: missing dist/entry.(m)js (build output).");
}

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.2.6-1",
"version": "2026.2.6-3",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"keywords": [],
"license": "MIT",

View File

@@ -6,7 +6,7 @@ WORKDIR /app
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
COPY src ./src
COPY test ./test
COPY scripts ./scripts

View File

@@ -80,10 +80,20 @@ LOGINCTL
fi
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
npm_bin="/tmp/npm-prefix/bin/openclaw"
npm_entry="/tmp/npm-prefix/lib/node_modules/openclaw/dist/index.js"
git_entry="/app/dist/index.js"
git_cli="/app/openclaw.mjs"
npm_bin="/tmp/npm-prefix/bin/openclaw"
npm_root="/tmp/npm-prefix/lib/node_modules/openclaw"
if [ -f "$npm_root/dist/index.mjs" ]; then
npm_entry="$npm_root/dist/index.mjs"
else
npm_entry="$npm_root/dist/index.js"
fi
if [ -f "/app/dist/index.mjs" ]; then
git_entry="/app/dist/index.mjs"
else
git_entry="/app/dist/index.js"
fi
git_cli="/app/openclaw.mjs"
assert_entrypoint() {
local unit_path="$1"

View File

@@ -31,7 +31,7 @@ echo "Starting gateway container..."
-e "OPENCLAW_SKIP_CRON=1" \
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
"$IMAGE_NAME" \
bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
bash -lc "entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
echo "Waiting for gateway to come up..."
ready=0
@@ -77,9 +77,9 @@ docker run --rm \
-e "GW_URL=ws://$GW_NAME:$PORT" \
-e "GW_TOKEN=$TOKEN" \
"$IMAGE_NAME" \
bash -lc "node - <<'NODE'
bash -lc "node --import tsx - <<'NODE'
import { WebSocket } from \"ws\";
import { PROTOCOL_VERSION } from \"./dist/gateway/protocol/index.js\";
import { PROTOCOL_VERSION } from \"./src/gateway/protocol/index.ts\";
const url = process.env.GW_URL;
const token = process.env.GW_TOKEN;

View File

@@ -10,9 +10,20 @@ docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running onboarding E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
trap "" PIPE
export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
trap "" PIPE
export TERM=xterm-256color
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
# tsdown may emit dist/index.js or dist/index.mjs depending on runtime/bundler.
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
export OPENCLAW_ENTRY
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
export PATH="/tmp/openclaw-bin:$PATH"
@@ -82,10 +93,10 @@ TRASH
done
}
start_gateway() {
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
}
start_gateway() {
node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
GATEWAY_PID="$!"
}
wait_for_gateway() {
for _ in $(seq 1 20); do
@@ -184,9 +195,9 @@ TRASH
local send_fn="$3"
local validate_fn="${4:-}"
# Default onboarding command wrapper.
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
}
# Default onboarding command wrapper.
run_wizard_cmd "$case_name" "$home_dir" "node \"$OPENCLAW_ENTRY\" onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
}
make_home() {
mktemp -d "/tmp/openclaw-e2e-$1.XXXXXX"
@@ -263,14 +274,14 @@ TRASH
send "" 1.0
}
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
export HOME="$home_dir"
mkdir -p "$HOME"
node dist/index.js onboard \
--non-interactive \
--accept-risk \
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
export HOME="$home_dir"
mkdir -p "$HOME"
node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
@@ -343,11 +354,11 @@ NODE
local home_dir
home_dir="$(make_home remote-non-interactive)"
export HOME="$home_dir"
mkdir -p "$HOME"
# Smoke test non-interactive remote config write.
node dist/index.js onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
mkdir -p "$HOME"
# Smoke test non-interactive remote config write.
node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
--mode remote \
--remote-url ws://gateway.local:18789 \
--remote-token remote-token \
--skip-skills \
--skip-health
@@ -388,7 +399,7 @@ NODE
export HOME="$home_dir"
mkdir -p "$HOME/.openclaw"
# Seed a remote config to exercise reset path.
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
{
"agents": { "defaults": { "workspace": "/root/old" } },
"gateway": {
@@ -398,9 +409,9 @@ NODE
}
JSON
node dist/index.js onboard \
--non-interactive \
--accept-risk \
node "$OPENCLAW_ENTRY" onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
@@ -438,10 +449,10 @@ NODE
}
run_case_channels() {
local home_dir
home_dir="$(make_home channels)"
# Channels-only configure flow.
run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow
local home_dir
home_dir="$(make_home channels)"
# Channels-only configure flow.
run_wizard_cmd channels "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
config_path="$HOME/.openclaw/openclaw.json"
assert_file "$config_path"
@@ -483,7 +494,7 @@ NODE
export HOME="$home_dir"
mkdir -p "$HOME/.openclaw"
# Seed skills config to ensure it survives the wizard.
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
{
"skills": {
"allowBundled": ["__none__"],
@@ -492,7 +503,7 @@ NODE
}
JSON
run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
config_path="$HOME/.openclaw/openclaw.json"
assert_file "$config_path"

View File

@@ -8,11 +8,21 @@ echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running plugins Docker E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
if [ -f dist/index.mjs ]; then
OPENCLAW_ENTRY="dist/index.mjs"
elif [ -f dist/index.js ]; then
OPENCLAW_ENTRY="dist/index.js"
else
echo "Missing dist/index.(m)js (build output):"
ls -la dist || true
exit 1
fi
export OPENCLAW_ENTRY
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
export HOME="$home_dir"
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
export HOME="$home_dir"
mkdir -p "$HOME/.openclaw/extensions/demo-plugin"
cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'"'"'JS'"'"'
@@ -38,7 +48,7 @@ JS
}
JSON
node dist/index.js plugins list --json > /tmp/plugins.json
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
@@ -99,8 +109,8 @@ JS
JSON
tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package
node dist/index.js plugins install /tmp/demo-plugin-tgz.tgz
node dist/index.js plugins list --json > /tmp/plugins2.json
node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
@@ -145,8 +155,8 @@ JS
}
JSON
node dist/index.js plugins install "$dir_plugin"
node dist/index.js plugins list --json > /tmp/plugins3.json
node "$OPENCLAW_ENTRY" plugins install "$dir_plugin"
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");
@@ -192,8 +202,8 @@ JS
}
JSON
node dist/index.js plugins install "file:$file_pack_dir/package"
node dist/index.js plugins list --json > /tmp/plugins4.json
node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package"
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json
node - <<'"'"'NODE'"'"'
const fs = require("node:fs");

View File

@@ -7,9 +7,9 @@ import { join, resolve } from "node:path";
type PackFile = { path: string };
type PackResult = { files?: PackFile[] };
const requiredPaths = [
"dist/index.js",
"dist/entry.js",
const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"],
["dist/entry.js", "dist/entry.mjs"],
"dist/plugin-sdk/index.js",
"dist/plugin-sdk/index.d.ts",
"dist/build-info.json",
@@ -82,7 +82,14 @@ function main() {
const files = results.flatMap((entry) => entry.files ?? []);
const paths = new Set(files.map((file) => file.path));
const missing = requiredPaths.filter((path) => !paths.has(path));
const missing = requiredPathGroups
.flatMap((group) => {
if (Array.isArray(group)) {
return group.some((path) => paths.has(path)) ? [] : [group.join(" or ")];
}
return paths.has(group) ? [] : [group];
})
.toSorted();
const forbidden = [...paths].filter((path) =>
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)),
);

View File

@@ -29,15 +29,13 @@ const shardCount = isWindowsCi
? shardOverride
: 2
: 1;
const windowsCiArgs = isWindowsCi
? ["--no-file-parallelism", "--dangerouslyIgnoreUnhandledErrors"]
: [];
const windowsCiArgs = isWindowsCi ? ["--dangerouslyIgnoreUnhandledErrors"] : [];
const passthroughArgs = process.argv.slice(2);
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
const resolvedOverride =
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway");
const serialRuns = isWindowsCi ? runs : runs.filter((entry) => entry.name === "gateway");
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
const serialRuns = runs.filter((entry) => entry.name === "gateway");
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const parallelCount = Math.max(1, parallelRuns.length);
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelCount));

View File

@@ -7,9 +7,13 @@ const distDir = path.join(rootDir, "dist");
const cliDir = path.join(distDir, "cli");
const findCandidates = () =>
fs
.readdirSync(distDir)
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
fs.readdirSync(distDir).filter((entry) => {
if (!entry.startsWith("daemon-cli-")) {
return false;
}
// tsdown can emit either .js or .mjs depending on bundler settings/runtime.
return entry.endsWith(".js") || entry.endsWith(".mjs");
});
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
// Retry briefly to avoid flaky builds.

View File

@@ -257,6 +257,30 @@ describe("getApiKeyForModel", () => {
}
});
it("resolves Qianfan API key from env", async () => {
const previous = process.env.QIANFAN_API_KEY;
try {
process.env.QIANFAN_API_KEY = "qianfan-test-key";
vi.resetModules();
const { resolveApiKeyForProvider } = await import("./model-auth.js");
const resolved = await resolveApiKeyForProvider({
provider: "qianfan",
store: { version: 1, profiles: {} },
});
expect(resolved.apiKey).toBe("qianfan-test-key");
expect(resolved.source).toContain("QIANFAN_API_KEY");
} finally {
if (previous === undefined) {
delete process.env.QIANFAN_API_KEY;
} else {
process.env.QIANFAN_API_KEY = previous;
}
}
});
it("resolves Vercel AI Gateway API key from env", async () => {
const previousGatewayKey = process.env.AI_GATEWAY_API_KEY;

View File

@@ -302,6 +302,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
venice: "VENICE_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
qianfan: "QIANFAN_API_KEY",
ollama: "OLLAMA_API_KEY",
};
const envVar = envMap[normalized];

View File

@@ -0,0 +1,25 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveImplicitProviders } from "./models-config.providers.js";
describe("Qianfan provider", () => {
it("should include qianfan when QIANFAN_API_KEY is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const previous = process.env.QIANFAN_API_KEY;
process.env.QIANFAN_API_KEY = "test-key";
try {
const providers = await resolveImplicitProviders({ agentDir });
expect(providers?.qianfan).toBeDefined();
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
} finally {
if (previous === undefined) {
delete process.env.QIANFAN_API_KEY;
} else {
process.env.QIANFAN_API_KEY = previous;
}
}
});
});

View File

@@ -80,6 +80,17 @@ const OLLAMA_DEFAULT_COST = {
cacheWrite: 0,
};
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
const QIANFAN_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
interface OllamaModel {
name: string;
modified_at: string;
@@ -403,6 +414,33 @@ async function buildOllamaProvider(): Promise<ProviderConfig> {
};
}
export function buildQianfanProvider(): ProviderConfig {
return {
baseUrl: QIANFAN_BASE_URL,
api: "openai-completions",
models: [
{
id: QIANFAN_DEFAULT_MODEL_ID,
name: "DEEPSEEK V3.2",
reasoning: true,
input: ["text"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
},
{
id: "ernie-5.0-thinking-preview",
name: "ERNIE-5.0-Thinking-Preview",
reasoning: true,
input: ["text", "image"],
cost: QIANFAN_DEFAULT_COST,
contextWindow: 119000,
maxTokens: 64000,
},
],
};
}
export async function resolveImplicitProviders(params: {
agentDir: string;
}): Promise<ModelsConfig["providers"]> {
@@ -498,6 +536,13 @@ export async function resolveImplicitProviders(params: {
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
}
const qianfanKey =
resolveEnvApiKeyVarName("qianfan") ??
resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore });
if (qianfanKey) {
providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey };
}
return providers;
}

Some files were not shown because too many files have changed in this diff Show More