Compare commits

..

5 Commits

Author SHA1 Message Date
Onur Solmaz
1eb2fbb618 Docs: keep CI backlog out of policy doc 2026-04-16 18:27:08 +02:00
Onur Solmaz
0f9058b1b7 Docs: list remaining CI coverage gaps 2026-04-16 18:18:32 +02:00
Onur Solmaz
42c822286b Docs: drop unrelated instruction edits 2026-04-16 18:03:39 +02:00
Onur Solmaz
d0472a8da1 Docs: add context to testing CI policy 2026-04-16 17:59:27 +02:00
Onur Solmaz
a84b0515cd Docs: clarify testing CI policy 2026-04-16 17:46:25 +02:00
1621 changed files with 41613 additions and 72617 deletions

View File

@@ -22,7 +22,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- Windows: `90m`
- aggregate npm-update wrapper: `150m`
If a lane hits the cap, stop there, inspect the newest `/tmp/openclaw-parallels-*` run directory and phase log, then fix or rerun the smallest affected lane. Do not keep waiting on a capped lane.
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: install phases should finish within 7 minutes, and update phases should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, or `install-baseline-package` exceeds 420s, or a phase named `update-dev` / same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
- Actual OpenClaw npm install/update phases are a stricter budget than whole lanes: they should finish within 5 minutes. If a phase named `install-main`, `install-latest`, `install-baseline`, `install-baseline-package`, `update-dev`, or same-guest `openclaw update` exceeds 300s, treat it as a failure/harness bug and start diagnosis from that phase log. Do not wait for a longer lane cap.
- For a full OS matrix, prefer running independent guest-family lanes in parallel when host capacity allows:
- `timeout --foreground 75m pnpm test:parallels:macos -- --json`
- `timeout --foreground 90m pnpm test:parallels:windows -- --json`

View File

@@ -127,7 +127,7 @@ The `fetch-content` output for `discussion_comment` includes `comment_node_id` a
The recreated comment should follow this format:
```
> **Note:** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
> **Note from maintainer (@<LOGIN>):** The original comment by @<AUTHOR> has been removed due to secret leakage. Below is the redacted version of the original content.
---

View File

@@ -52,11 +52,7 @@ function ghGraphQL(query, options = {}) {
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (
result.stderr ||
result.stdout ||
`gh exited with status ${result.status}`
).trim();
const details = (result.stderr || result.stdout || `gh exited with status ${result.status}`).trim();
fail(`${message}: ${details}`);
}
if (Array.isArray(result?.errors) && result.errors.length > 0) {
@@ -77,7 +73,9 @@ function formatGraphQLAfterClause(cursor) {
}
function findDiscussionCommentNode(nodes, discussionCommentDbId) {
return nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null;
return (
nodes.find((node) => String(node.databaseId) === String(discussionCommentDbId)) || null
);
}
function fetchDiscussionReplyPage(commentNodeId, cursor) {
@@ -171,13 +169,9 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
while (!reply && hasMoreReplies) {
const replyPage = fetchDiscussionReplyPage(topLevelComment.id, replyCursor);
failOnGraphQLFailure(
replyPage,
`Failed to fetch replies for discussion comment ${topLevelComment.id}`,
);
failOnGraphQLFailure(replyPage, `Failed to fetch replies for discussion comment ${topLevelComment.id}`);
const replies = replyPage?.data?.node?.replies;
if (!replies)
fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
if (!replies) fail(`Failed to paginate replies for discussion comment ${topLevelComment.id}`);
reply = findDiscussionCommentNode(replies.nodes, discussionCommentDbId);
hasMoreReplies = replies.pageInfo.hasNextPage;
@@ -195,7 +189,9 @@ function fetchDiscussionComment(discussionNumber, discussionCommentDbId) {
}
function createDiscussionComment(discussionNodeId, body, replyToNodeId) {
const replyToClause = replyToNodeId ? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"` : "";
const replyToClause = replyToNodeId
? `, replyToId: "${escapeGraphQLString(replyToNodeId)}"`
: "";
const result = ghGraphQL(
`mutation { addDiscussionComment(input: { discussionId: "${escapeGraphQLString(discussionNodeId)}"${replyToClause}, body: "${escapeGraphQLString(body)}" }) { comment { id url } } }`,
);
@@ -265,10 +261,7 @@ function cmdFetchContent(locationJson) {
const discussionNumber = urlMatch[1];
const discussionCommentDbId = urlMatch[2];
const { discussionId, comment } = fetchDiscussionComment(
discussionNumber,
discussionCommentDbId,
);
const { discussionId, comment } = fetchDiscussionComment(discussionNumber, discussionCommentDbId);
if (!comment)
fail(
`Discussion comment #${discussionCommentDbId} not found in discussion #${discussionNumber}`,

View File

@@ -11,8 +11,6 @@ self-hosted-runner:
- blacksmith-16vcpu-windows-2025
- blacksmith-32vcpu-windows-2025
- blacksmith-16vcpu-ubuntu-2404-arm
- blacksmith-6vcpu-macos-latest
- blacksmith-12vcpu-macos-latest
# Ignore patterns for known issues
paths:

File diff suppressed because it is too large Load Diff

View File

@@ -140,8 +140,7 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_MODEL: gpt-5.4
OPENCLAW_CONTROL_UI_I18N_THINKING: low
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${{ matrix.locale }}" --write
- name: Commit and push locale updates
env:

View File

@@ -362,36 +362,28 @@ jobs:
- name: Create and push default manifest
shell: bash
env:
TAGS: ${{ steps.tags.outputs.value }}
AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }}
run: |
set -euo pipefail
mapfile -t tags <<< "${TAGS}"
mapfile -t tags <<< "${{ steps.tags.outputs.value }}"
args=()
for tag in "${tags[@]}"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" \
"${AMD64_DIGEST}" \
"${ARM64_DIGEST}"
${{ needs.build-amd64.outputs.digest }} \
${{ needs.build-arm64.outputs.digest }}
- name: Create and push slim manifest
shell: bash
env:
SLIM_TAGS: ${{ steps.tags.outputs.slim }}
AMD64_SLIM_DIGEST: ${{ needs.build-amd64.outputs.slim-digest }}
ARM64_SLIM_DIGEST: ${{ needs.build-arm64.outputs.slim-digest }}
run: |
set -euo pipefail
mapfile -t tags <<< "${SLIM_TAGS}"
mapfile -t tags <<< "${{ steps.tags.outputs.slim }}"
args=()
for tag in "${tags[@]}"; do
[ -z "$tag" ] && continue
args+=("-t" "$tag")
done
docker buildx imagetools create "${args[@]}" \
"${AMD64_SLIM_DIGEST}" \
"${ARM64_SLIM_DIGEST}"
${{ needs.build-amd64.outputs.slim-digest }} \
${{ needs.build-arm64.outputs.slim-digest }}

View File

@@ -1,67 +1,12 @@
name: OpenClaw Cross-OS Release Checks (Reusable)
on:
workflow_dispatch:
inputs:
ref:
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
required: true
default: main
type: string
workflow_ref:
description: Optional openclaw/openclaw ref that provides the reusable workflow harness
required: false
default: ""
type: string
provider:
description: Provider lane to use for onboarding and the end-to-end turn
required: true
default: openai
type: choice
options:
- openai
- anthropic
- minimax
mode:
description: Which release-check lanes to run
required: true
default: both
type: choice
options:
- fresh
- upgrade
- both
previous_version:
description: Optional baseline version for installer/dev-update and packaged upgrade
required: false
default: ""
type: string
ubuntu_runner:
description: Optional Linux runner label override
required: false
default: ""
type: string
windows_runner:
description: Optional Windows runner label override
required: false
default: ""
type: string
macos_runner:
description: Optional macOS runner label override
required: false
default: ""
type: string
workflow_call:
inputs:
ref:
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
required: true
type: string
workflow_ref:
description: Optional openclaw/openclaw ref that provides the reusable workflow harness
required: false
default: ""
type: string
provider:
description: Provider lane to use for onboarding and the end-to-end turn
required: true
@@ -97,14 +42,6 @@ on:
required: false
MINIMAX_API_KEY:
required: false
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN:
required: false
OPENCLAW_DISCORD_SMOKE_GUILD_ID:
required: false
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID:
required: false
permissions: read-all
concurrency:
group: openclaw-cross-os-release-checks-${{ inputs.ref }}-${{ inputs.provider }}-${{ inputs.mode }}
@@ -115,11 +52,12 @@ env:
NODE_VERSION: "24.x"
PNPM_VERSION: "10.32.1"
OPENCLAW_REPOSITORY: openclaw/openclaw
TSX_VERSION: "4.21.0"
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_spec: ${{ steps.baseline.outputs.value }}
@@ -127,7 +65,6 @@ jobs:
candidate_version: ${{ steps.candidate_metadata.outputs.version }}
matrix: ${{ steps.matrix.outputs.value }}
source_sha: ${{ steps.candidate_metadata.outputs.source_sha }}
workflow_ref: ${{ steps.workflow_ref.outputs.value }}
steps:
- name: Validate provider secret availability
env:
@@ -153,109 +90,9 @@ jobs:
;;
esac
- name: Resolve workflow ref
id: workflow_ref
env:
INPUT_WORKFLOW_REF: ${{ inputs.workflow_ref }}
CALLER_REPOSITORY: ${{ github.repository }}
CURRENT_SHA: ${{ github.sha }}
WORKFLOW_CONTEXT_REF: ${{ github.workflow_ref }}
WORKFLOW_REPOSITORY: ${{ env.OPENCLAW_REPOSITORY }}
run: |
set -euo pipefail
resolve_unique_remote_ref() {
local remote_url="$1"
shift
local -a refs=("$@")
local -a matches=()
local ref=""
for ref in "${refs[@]}"; do
[[ -n "${ref}" ]] || continue
mapfile -t matches < <(
git ls-remote "${remote_url}" "${ref}" | awk '{print $1}' | awk '!seen[$0]++'
)
if [[ "${#matches[@]}" -eq 0 ]]; then
continue
fi
if [[ "${#matches[@]}" -ne 1 ]]; then
return 2
fi
printf '%s\n' "${matches[0]}"
return 0
done
return 1
}
if [[ -n "${INPUT_WORKFLOW_REF}" ]]; then
TARGET_REF="${INPUT_WORKFLOW_REF}"
elif [[ "${CALLER_REPOSITORY}" == "${WORKFLOW_REPOSITORY}" ]]; then
TARGET_REF="${CURRENT_SHA}"
elif [[ "${WORKFLOW_CONTEXT_REF}" == "${WORKFLOW_REPOSITORY}/"* ]] && [[ "${WORKFLOW_CONTEXT_REF}" == *"@"* ]]; then
TARGET_REF="${WORKFLOW_CONTEXT_REF##*@}"
else
echo "Failed to infer workflow ref from github.workflow_ref=${WORKFLOW_CONTEXT_REF}" >&2
exit 1
fi
if [[ "${TARGET_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "value=${TARGET_REF}" >> "$GITHUB_OUTPUT"
exit 0
fi
REMOTE_URL="https://github.com/${WORKFLOW_REPOSITORY}.git"
if [[ "${TARGET_REF}" == refs/* ]]; then
if [[ "${TARGET_REF}" == refs/tags/* ]]; then
mapfile -t MATCHES < <(
resolve_unique_remote_ref "${REMOTE_URL}" "${TARGET_REF}^{}" "${TARGET_REF}" || true
)
else
mapfile -t MATCHES < <(resolve_unique_remote_ref "${REMOTE_URL}" "${TARGET_REF}" || true)
fi
else
mapfile -t BRANCH_MATCHES < <(
resolve_unique_remote_ref "${REMOTE_URL}" "refs/heads/${TARGET_REF}" || true
)
mapfile -t TAG_MATCHES < <(
resolve_unique_remote_ref "${REMOTE_URL}" "refs/tags/${TARGET_REF}^{}" "refs/tags/${TARGET_REF}" || true
)
MATCH_COUNT=$(( ${#BRANCH_MATCHES[@]} + ${#TAG_MATCHES[@]} ))
if [[ "${MATCH_COUNT}" -eq 1 ]]; then
if [[ "${#BRANCH_MATCHES[@]}" -eq 1 ]]; then
MATCHES=("${BRANCH_MATCHES[0]}")
else
MATCHES=("${TAG_MATCHES[0]}")
fi
elif [[ "${MATCH_COUNT}" -eq 0 ]]; then
MATCHES=()
else
echo "Workflow ref resolved ambiguously: ${TARGET_REF}" >&2
exit 1
fi
fi
case "${#MATCHES[@]}" in
1)
echo "value=${MATCHES[0]}" >> "$GITHUB_OUTPUT"
;;
0)
echo "Failed to resolve workflow ref: ${TARGET_REF}" >&2
exit 1
;;
*)
echo "Workflow ref resolved ambiguously: ${TARGET_REF}" >&2
exit 1
;;
esac
- name: Checkout workflow repo
- name: Checkout caller release workflow repo
uses: actions/checkout@v6
with:
repository: ${{ env.OPENCLAW_REPOSITORY }}
ref: ${{ steps.workflow_ref.outputs.value }}
path: workflow
fetch-depth: 1
persist-credentials: false
@@ -286,7 +123,7 @@ jobs:
env:
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare
run: |
pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \
--prepare-only \
--source-dir source \
--output-dir "${OUTPUT_DIR}"
@@ -361,7 +198,6 @@ jobs:
- name: Resolve runner matrix
id: matrix
env:
INPUT_REF: ${{ inputs.ref }}
INPUT_MODE: ${{ inputs.mode }}
INPUT_UBUNTU_RUNNER: ${{ inputs.ubuntu_runner }}
INPUT_WINDOWS_RUNNER: ${{ inputs.windows_runner }}
@@ -370,30 +206,53 @@ jobs:
VAR_WINDOWS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_WINDOWS_RUNNER }}
VAR_MACOS_RUNNER: ${{ vars.OPENCLAW_RELEASE_CHECKS_MACOS_RUNNER }}
run: |
MATRIX_JSON="$(pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
--resolve-matrix \
--ref "${INPUT_REF}" \
--mode "${INPUT_MODE}" \
--ubuntu-runner "${INPUT_UBUNTU_RUNNER}" \
--windows-runner "${INPUT_WINDOWS_RUNNER}" \
--macos-runner "${INPUT_MACOS_RUNNER}")"
echo "value=${MATRIX_JSON}" >> "$GITHUB_OUTPUT"
node <<'NODE' >>"$GITHUB_OUTPUT"
const pick = (...values) => values.find((value) => typeof value === "string" && value.trim().length > 0)?.trim();
const lanes = (process.env.INPUT_MODE ?? "both") === "both" ? ["fresh", "upgrade"] : [process.env.INPUT_MODE ?? "both"];
const runners = [
{
os_id: "ubuntu",
display_name: "Linux",
runner: pick(process.env.INPUT_UBUNTU_RUNNER, process.env.VAR_UBUNTU_RUNNER, "ubuntu-latest"),
artifact_name: "linux",
},
{
os_id: "windows",
display_name: "Windows",
runner: pick(
process.env.INPUT_WINDOWS_RUNNER,
process.env.VAR_WINDOWS_RUNNER,
"blacksmith-32vcpu-windows-2025",
),
artifact_name: "windows",
},
{
os_id: "macos",
display_name: "macOS",
runner: pick(process.env.INPUT_MACOS_RUNNER, process.env.VAR_MACOS_RUNNER, "macos-latest-xlarge"),
artifact_name: "macos",
},
];
const matrix = {
include: runners.flatMap((runner) => lanes.map((lane) => ({ ...runner, lane }))),
};
process.stdout.write(`value=${JSON.stringify(matrix)}\n`);
NODE
cross_os_release_checks:
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
name: "${{ matrix.display_name }} / ${{ matrix.lane }}"
needs: prepare
permissions:
contents: read
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
steps:
- name: Checkout workflow repo
- name: Checkout caller release workflow repo
uses: actions/checkout@v6
with:
repository: ${{ env.OPENCLAW_REPOSITORY }}
ref: ${{ needs.prepare.outputs.workflow_ref }}
path: workflow
fetch-depth: 1
persist-credentials: false
@@ -415,7 +274,7 @@ jobs:
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate
- name: Download baseline artifact
if: ${{ matrix.suite == 'packaged-upgrade' }}
if: ${{ matrix.lane == 'upgrade' }}
uses: actions/download-artifact@v8
with:
name: openclaw-cross-os-release-checks-baseline-${{ github.run_id }}
@@ -427,35 +286,24 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }}
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }}
OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }}
run: |
DISCORD_ARGS=()
if [[ -n "${OPENCLAW_DISCORD_SMOKE_BOT_TOKEN}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_GUILD_ID}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_CHANNEL_ID}" ]]; then
DISCORD_ARGS+=(--run-discord-roundtrip true)
fi
pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
node --disable-warning=ExperimentalWarning scripts/openclaw-cross-os-release-checks.ts \
--candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \
--candidate-version "${{ needs.prepare.outputs.candidate_version }}" \
--source-sha "${{ needs.prepare.outputs.source_sha }}" \
--baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \
--previous-version "${{ inputs.previous_version }}" \
--baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \
--provider "${{ inputs.provider }}" \
--mode "${{ matrix.lane }}" \
--suite "${{ matrix.suite }}" \
--ref "${{ inputs.ref }}" \
"${DISCORD_ARGS[@]}" \
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}"
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}"
- name: Summarize release checks
if: always()
shell: bash
env:
SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}/summary.md
SUMMARY_PATH: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}/summary.md
run: |
if [[ -f "${SUMMARY_PATH}" ]]; then
cat "${SUMMARY_PATH}" >> "$GITHUB_STEP_SUMMARY"
@@ -467,6 +315,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.suite }}-${{ github.run_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
name: openclaw-cross-os-release-checks-${{ matrix.artifact_name }}-${{ matrix.lane }}-${{ github.run_id }}
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.lane }}
if-no-files-found: error

View File

@@ -1,663 +0,0 @@
name: OpenClaw Live And E2E Checks (Reusable)
on:
workflow_dispatch:
inputs:
ref:
description: Ref, tag, or SHA to validate
required: true
default: main
type: string
include_repo_e2e:
description: Whether to run pnpm test:e2e plus repo-specific extra E2E lanes
required: false
default: true
type: boolean
include_release_path_suites:
description: Whether to run the Docker release-path suites
required: false
default: true
type: boolean
include_openwebui:
description: Whether to run the Open WebUI Docker smoke
required: false
default: true
type: boolean
include_live_suites:
description: Whether to run live-provider coverage
required: false
default: true
type: boolean
workflow_call:
inputs:
ref:
description: Ref, tag, or SHA to validate
required: true
type: string
include_repo_e2e:
description: Whether to run pnpm test:e2e
required: false
default: false
type: boolean
include_release_path_suites:
description: Whether to run the Docker release-path suites
required: false
default: false
type: boolean
include_openwebui:
description: Whether to run the Open WebUI Docker smoke
required: false
default: true
type: boolean
include_live_suites:
description: Whether to run live-provider coverage
required: false
default: true
type: boolean
secrets:
OPENAI_API_KEY:
required: false
OPENAI_BASE_URL:
required: false
ANTHROPIC_API_KEY:
required: false
ANTHROPIC_API_KEY_OLD:
required: false
ANTHROPIC_API_TOKEN:
required: false
BYTEPLUS_API_KEY:
required: false
CEREBRAS_API_KEY:
required: false
DASHSCOPE_API_KEY:
required: false
GROQ_API_KEY:
required: false
KIMI_API_KEY:
required: false
MODELSTUDIO_API_KEY:
required: false
MOONSHOT_API_KEY:
required: false
MISTRAL_API_KEY:
required: false
MINIMAX_API_KEY:
required: false
OPENCODE_API_KEY:
required: false
OPENCODE_ZEN_API_KEY:
required: false
OPENCLAW_LIVE_BROWSER_CDP_URL:
required: false
OPENCLAW_LIVE_SETUP_TOKEN:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_MODEL:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE:
required: false
OPENCLAW_LIVE_SETUP_TOKEN_VALUE:
required: false
GEMINI_API_KEY:
required: false
GOOGLE_API_KEY:
required: false
OPENROUTER_API_KEY:
required: false
QWEN_API_KEY:
required: false
FAL_KEY:
required: false
RUNWAY_API_KEY:
required: false
DEEPGRAM_API_KEY:
required: false
TOGETHER_API_KEY:
required: false
VYDRA_API_KEY:
required: false
XAI_API_KEY:
required: false
ZAI_API_KEY:
required: false
Z_AI_API_KEY:
required: false
BYTEPLUS_ACCESS_KEY_ID:
required: false
BYTEPLUS_SECRET_ACCESS_KEY:
required: false
CLAUDE_CODE_OAUTH_TOKEN:
required: false
OPENCLAW_CODEX_AUTH_JSON:
required: false
OPENCLAW_CODEX_CONFIG_TOML:
required: false
OPENCLAW_CLAUDE_JSON:
required: false
OPENCLAW_CLAUDE_CREDENTIALS_JSON:
required: false
OPENCLAW_CLAUDE_SETTINGS_JSON:
required: false
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON:
required: false
OPENCLAW_GEMINI_SETTINGS_JSON:
required: false
permissions:
contents: read
pull-requests: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.32.1"
jobs:
validate_selected_ref:
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_sha: ${{ steps.validate.outputs.selected_sha }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
GH_TOKEN: ${{ github.token }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_sha="$(git rev-parse HEAD)"
trusted_reason=""
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
trusted_reason="release-tag"
else
pr_head_count="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
)"
if [[ "$pr_head_count" != "0" ]]; then
trusted_reason="open-pr-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
exit 1
fi
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "Validated ref: \`${INPUT_REF}\`"
echo "Resolved SHA: \`$selected_sha\`"
echo "Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_LIVE_CACHE_TEST: "1"
OPENCLAW_LIVE_TEST: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
- name: Validate live cache credentials
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing OPENAI_API_KEY secret for live-cache validation." >&2
exit 1
fi
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "Missing ANTHROPIC_API_KEY secret for live-cache validation." >&2
exit 1
fi
- name: Verify live prompt cache floors
run: pnpm test:live:cache
validate_repo_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 90
env:
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
- name: Build dist for repo E2E
run: pnpm build
- name: Run repo E2E suite
run: pnpm test:e2e
validate_special_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e || inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- suite_id: openshell-e2e
label: OpenShell repo E2E
command: pnpm test:e2e:openshell
timeout_minutes: 120
requires_repo_e2e: true
requires_live_suites: false
- suite_id: openai-ws-stream-live-e2e
label: OpenAI WebSocket live E2E
command: pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts
timeout_minutes: 90
requires_repo_e2e: false
requires_live_suites: true
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_E2E_WORKERS: "1"
OPENCLAW_VITEST_MAX_WORKERS: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
- name: Build dist for special E2E
if: |
(inputs.include_repo_e2e && matrix.requires_repo_e2e) ||
(inputs.include_live_suites && matrix.requires_live_suites)
run: pnpm build
- name: Configure suite-specific env
shell: bash
run: |
set -euo pipefail
case "${{ matrix.suite_id }}" in
openai-ws-stream-live-e2e)
echo "OPENAI_LIVE_TEST=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_TEST=1" >> "$GITHUB_ENV"
;;
esac
- name: Validate suite credentials
shell: bash
run: |
set -euo pipefail
case "${{ matrix.suite_id }}" in
openai-ws-stream-live-e2e)
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for the OpenAI WebSocket live E2E suite." >&2
exit 1
}
;;
esac
- name: Run ${{ matrix.label }}
if: |
(inputs.include_repo_e2e && matrix.requires_repo_e2e) ||
(inputs.include_live_suites && matrix.requires_live_suites)
run: ${{ matrix.command }}
validate_docker_e2e:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- suite_id: docker-onboard
label: Onboarding Docker E2E
command: pnpm test:docker:onboard
timeout_minutes: 60
release_path: true
openwebui_only: false
- suite_id: docker-gateway-network
label: Gateway Network Docker E2E
command: pnpm test:docker:gateway-network
timeout_minutes: 60
release_path: true
openwebui_only: false
- suite_id: docker-mcp-channels
label: MCP Channels Docker E2E
command: pnpm test:docker:mcp-channels
timeout_minutes: 60
release_path: true
openwebui_only: false
- suite_id: docker-plugins
label: Plugins Docker E2E
command: pnpm test:docker:plugins
timeout_minutes: 75
release_path: true
openwebui_only: false
- suite_id: docker-doctor-switch
label: Doctor Install Switch Docker E2E
command: pnpm test:docker:doctor-switch
timeout_minutes: 60
release_path: true
openwebui_only: false
- suite_id: docker-qr
label: QR Import Docker E2E
command: pnpm test:docker:qr
timeout_minutes: 60
release_path: true
openwebui_only: false
- suite_id: docker-install-e2e
label: Installer Docker E2E
command: pnpm test:install:e2e
timeout_minutes: 120
release_path: true
openwebui_only: false
- suite_id: docker-openwebui
label: Open WebUI Docker E2E
command: pnpm test:docker:openwebui
timeout_minutes: 75
release_path: false
openwebui_only: true
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
- name: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Configure suite-specific env
shell: bash
run: |
set -euo pipefail
case "${{ matrix.suite_id }}" in
docker-install-e2e)
echo "OPENCLAW_E2E_MODELS=both" >> "$GITHUB_ENV"
;;
esac
- name: Validate suite credentials
shell: bash
run: |
set -euo pipefail
case "${{ matrix.suite_id }}" in
docker-install-e2e)
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for installer Docker E2E." >&2
exit 1
}
if [[ -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for installer Docker E2E." >&2
exit 1
fi
;;
docker-openwebui)
[[ -n "${OPENAI_API_KEY:-}" ]] || {
echo "OPENAI_API_KEY is required for the Open WebUI Docker smoke." >&2
exit 1
}
;;
esac
- name: Run ${{ matrix.label }}
if: |
(inputs.include_release_path_suites && matrix.release_path) ||
(inputs.include_openwebui && matrix.openwebui_only)
run: ${{ matrix.command }}
validate_live_provider_suites:
needs: validate_selected_ref
if: inputs.include_live_suites
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
matrix:
include:
- suite_id: live-all
label: pnpm test:live
command: pnpm test:live
timeout_minutes: 180
profile_env_only: false
- suite_id: live-models-docker
label: Docker live models
command: pnpm test:docker:live-models
timeout_minutes: 120
profile_env_only: false
- suite_id: live-gateway-docker
label: Docker live gateway
command: pnpm test:docker:live-gateway
timeout_minutes: 120
profile_env_only: false
- suite_id: live-cli-backend-docker
label: Docker live CLI backend
command: pnpm test:docker:live-cli-backend
timeout_minutes: 120
profile_env_only: false
- suite_id: live-acp-bind-docker
label: Docker live ACP bind
command: pnpm test:docker:live-acp-bind
timeout_minutes: 120
profile_env_only: false
- suite_id: live-codex-harness-docker
label: Docker live Codex harness
command: pnpm test:docker:live-codex-harness
timeout_minutes: 120
profile_env_only: false
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
OPENCLAW_LIVE_VIDEO_GENERATION_SKIP_PROVIDERS: ""
OPENCLAW_LIVE_VYDRA_VIDEO: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
fetch-depth: 0
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
- name: Hydrate live auth/profile inputs
run: bash scripts/ci-hydrate-live-auth.sh
- name: Configure suite-specific env
shell: bash
run: |
set -euo pipefail
if [[ "${{ matrix.profile_env_only }}" == "true" ]]; then
echo "OPENCLAW_DOCKER_PROFILE_ENV_ONLY=1" >> "$GITHUB_ENV"
fi
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
# The CLI backend Docker lane should exercise the same staged
# Codex auth path Peter uses locally so MCP cron creation and
# multimodal probes stay covered in CI. Replace the staged
# config.toml with a minimal CI-safe config so the repo stays
# trusted for MCP/tool use without inheriting maintainer-local
# provider/profile overrides that do not exist inside CI.
# Codex's workspace-write sandbox relies on user namespaces that
# this Docker lane does not provide, so run Codex unsandboxed
# inside the already-isolated container to keep MCP cron/tool
# execution representative instead of failing on nested sandbox
# setup.
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
# Keep CI on the API-key path for now. The staged Codex auth secret
# is currently stale, but the wrapper still supports codex-auth for
# local maintainer reruns without changing Peter's flow.
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
;;
live-acp-bind-docker)
if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV"
else
# The hydrated Gemini settings file only selects Gemini CLI auth
# mode. CI still needs a usable Gemini or Google API key before
# ACP bind can initialize a Gemini session.
echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex" >> "$GITHUB_ENV"
fi
;;
esac
- name: Run ${{ matrix.label }}
run: ${{ matrix.command }}

View File

@@ -397,10 +397,9 @@ jobs:
env:
OPENCLAW_PREPACK_PREPARED: "1"
OPENCLAW_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag }}
PUBLISH_TARBALL_PATH: ${{ steps.publish_tarball.outputs.path }}
run: |
set -euo pipefail
publish_target="${PUBLISH_TARBALL_PATH}"
publish_target="${{ steps.publish_tarball.outputs.path }}"
if [[ -n "${publish_target}" ]]; then
publish_target="./${publish_target}"
fi

View File

@@ -7,24 +7,6 @@ on:
description: Existing release tag or current full 40-character main commit SHA to validate (for example v2026.4.12 or 0123456789abcdef0123456789abcdef01234567)
required: true
type: string
provider:
description: Provider lane for cross-OS onboarding and the end-to-end agent turn
required: false
default: openai
type: choice
options:
- openai
- anthropic
- minimax
mode:
description: Which cross-OS release lanes to run
required: false
default: both
type: choice
options:
- fresh
- upgrade
- both
concurrency:
group: openclaw-release-checks-${{ inputs.ref }}
@@ -32,18 +14,18 @@ concurrency:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
PNPM_VERSION: "10.32.1"
jobs:
resolve_target:
# THIS WORKFLOW EXISTS SO RELEASE-TIME LIVE CHECKS CAN RUN WITHOUT BLOCKING npm PUBLISH.
# PUT THE SLOWER, EXTERNAL, OR SOMETIMES-FLAKY RELEASE CHECKS HERE INSTEAD OF
# RECOUPLING THEM TO openclaw-npm-release.yml.
validate_release_live_cache:
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 30
timeout-minutes: 60
permissions:
contents: read
outputs:
ref: ${{ steps.inputs.outputs.ref }}
sha: ${{ steps.ref.outputs.sha }}
provider: ${{ steps.inputs.outputs.provider }}
mode: ${{ steps.inputs.outputs.mode }}
steps:
- name: Require main workflow ref for release checks
env:
@@ -91,106 +73,48 @@ jobs:
git merge-base --is-ancestor HEAD origin/main
fi
- name: Capture selected inputs
id: inputs
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
use-sticky-disk: "false"
- name: Validate live cache credentials
env:
RELEASE_REF_INPUT: ${{ inputs.ref }}
RELEASE_PROVIDER_INPUT: ${{ inputs.provider }}
RELEASE_MODE_INPUT: ${{ inputs.mode }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
{
printf 'ref=%s\n' "$RELEASE_REF_INPUT"
printf 'provider=%s\n' "$RELEASE_PROVIDER_INPUT"
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
} >> "$GITHUB_OUTPUT"
if [[ -z "${OPENAI_API_KEY}" ]]; then
echo "Missing OPENAI_API_KEY secret for release checks." >&2
exit 1
fi
if [[ -z "${ANTHROPIC_API_KEY}" ]]; then
echo "Missing ANTHROPIC_API_KEY secret for release checks." >&2
exit 1
fi
# KEEP RELEASE-TIME LIVE COVERAGE HERE SO OPERATORS CAN RUN IT ON DEMAND
# WITHOUT MAKING THE PUBLISH PATH WAIT FOR A SLOW OR FLAKY EXTERNAL CHECK.
- name: Verify live prompt cache floors
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_LIVE_CACHE_TEST: "1"
OPENCLAW_LIVE_TEST: "1"
run: pnpm test:live:cache
- name: Summarize validated ref
env:
RELEASE_REF: ${{ inputs.ref }}
RELEASE_SHA: ${{ steps.ref.outputs.sha }}
RELEASE_PROVIDER: ${{ inputs.provider }}
RELEASE_MODE: ${{ inputs.mode }}
run: |
{
echo "## Release checks"
echo
echo "- Requested ref: \`${RELEASE_REF}\`"
echo "- Validated SHA: \`${RELEASE_SHA}\`"
echo "- Cross-OS provider: \`${RELEASE_PROVIDER}\`"
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- This run will execute cross-OS release validation plus the non-Parallels Docker/live/openwebui coverage from the CI migration plan."
echo "- Check: \`pnpm test:live:cache\`"
} >> "$GITHUB_STEP_SUMMARY"
cross_os_release_checks:
needs: [resolve_target]
permissions: read-all
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }}
OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }}
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
live_and_e2e_release_checks:
needs: [resolve_target]
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
include_repo_e2e: true
include_release_path_suites: true
include_openwebui: true
include_live_suites: true
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}

View File

@@ -1,74 +0,0 @@
name: OpenClaw Scheduled Live And E2E Checks
on:
schedule:
- cron: "23 4 * * *"
workflow_dispatch:
permissions:
contents: read
pull-requests: read
concurrency:
group: openclaw-scheduled-live-checks-${{ github.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
live_and_openwebui_checks:
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
ref: ${{ github.sha }}
include_repo_e2e: true
include_release_path_suites: false
include_openwebui: true
include_live_suites: true
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}

View File

@@ -1,12 +1,12 @@
# Repository Guidelines
- Repo: https://github.com/openclaw/openclaw
- In chat replies, file references must be repo-root relative only (example: `extensions/telegram/src/index.ts:80`); never absolute paths or `~/...`.
- In chat replies, file references must be repo-root relative only (example: `src/telegram/index.ts:80`); never absolute paths or `~/...`.
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, infra in `src/infra`, media pipeline in `src/media`, web provider helpers in `src/web` and `src/plugins/web-*provider*.ts`).
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. The bundled workspace plugin tree remains the internal package layout to avoid repo-wide churn from a rename.
@@ -17,8 +17,8 @@
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
- Core channel docs: `docs/channels/`
- Core channel code: `src/channels`, `src/routing`, `src/web`
- Bundled plugin channels: `extensions/<channel>/` (for example Discord, Telegram, Slack, Matrix, Zalo, ZaloUser, Voice Call)
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Bundled plugin channels: the workspace plugin tree (for example Matrix, Zalo, ZaloUser, Voice Call)
- When adding channels/plugins/apps/docs, update `.github/labeler.yml` and create matching GitHub labels (use existing channel/plugin label colors).
## Architecture Boundaries
@@ -73,7 +73,7 @@
- `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces.
- Bundled plugin contract boundary:
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-surface-loader.ts`, `src/plugins/public-surface-runtime.ts`, `src/plugins/provider-public-artifacts.ts`, `src/plugins/web-provider-public-artifacts.ts`
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
- Rule: keep manifest metadata, runtime registration, public SDK exports, and contract tests aligned. Do not create a hidden path around the declared plugin interfaces.
- Extension test boundary:
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
@@ -87,8 +87,6 @@
- `src/plugin-sdk/AGENTS.md` expands public SDK contract rules.
- `src/plugins/AGENTS.md` expands plugin loading, registry, and manifest rules.
- `src/gateway/protocol/AGENTS.md` expands typed Gateway protocol rules.
- `src/gateway/AGENTS.md` expands Gateway server hot-path and plugin artifact rules.
- `src/agents/AGENTS.md` expands agent test/import performance rules.
- `test/helpers/AGENTS.md` and `test/helpers/channels/AGENTS.md` expand shared test helper boundary rules.
- Plugin architecture direction:
- Keep a manifest-first control plane: discovery, validation, enablement, setup hints, and activation planning should stay metadata-driven by default.
@@ -119,8 +117,8 @@
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repos package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
- Pre-commit hooks are installed by the package `prepare` script (`git config core.hooksPath git-hooks`). The hook formats/lints staged source files and runs `pnpm check` unless the staged change is docs-only or `FAST_COMMIT=1` is set.
- `FAST_COMMIT=1` skips the repo-wide `pnpm check` inside the pre-commit hook only. The hook still runs targeted formatting/linting for staged files and restages formatter changes. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Pre-commit hooks: `prek install`. The hook runs the repo verification flow, including `pnpm check`.
- `FAST_COMMIT=1` skips the repo-wide `pnpm format` and `pnpm check` inside the pre-commit hook only. Use it when you intentionally want a faster commit path and are running equivalent targeted verification manually. It does not change CI and does not change what `pnpm check` itself does.
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
@@ -130,8 +128,8 @@
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Local agent/dev shells default to host-aware `OPENCLAW_LOCAL_CHECK=1` behavior for `pnpm tsgo` and `pnpm lint`; set `OPENCLAW_LOCAL_CHECK_MODE=throttled` to force the lower-memory profile, `OPENCLAW_LOCAL_CHECK_MODE=full` to keep lock-only behavior, or `OPENCLAW_LOCAL_CHECK=0` in CI/shared runs.
- Format check: `pnpm format:check` (oxfmt --check)
- Format fix: `pnpm format` or `pnpm format:fix` (oxfmt --write)
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Terminology:
- "gate" means a verification command or command set that must be green for the decision you are making.
- A local dev gate is the fast default loop, usually `pnpm check` plus any scoped test you actually need.
@@ -139,8 +137,8 @@
- A CI gate is whatever the relevant workflow enforces for that lane (for example `check`, `check-additional`, `build-smoke`, or release validation).
- Local dev gate: prefer `pnpm check` for the normal edit loop. It keeps the repo-architecture policy guards out of the default local loop.
- CI architecture gate: `check-additional` enforces architecture and boundary policy guards that are intentionally kept out of the default local loop.
- Formatting gate: the pre-commit hook runs targeted formatting on staged source files before `pnpm check`. If you want a repo-wide formatting-only preflight locally, run `pnpm format:check` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm check`; targeted formatting/linting still runs, so use that only when you are deliberately covering the touched surface some other way.
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hooks repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
- Generated baseline drift detection uses SHA-256 hash files under `docs/.generated/` (`.sha256` files tracked in git; full JSON baselines are gitignored, generated locally for inspection).
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
@@ -217,8 +215,6 @@
- Test performance guardrail: when production code already accepts `deps`, callbacks, or runtime injection, use that seam in tests before adding module-level mocks.
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
- Test performance guardrail: when replacing a slow integration test with helper-level coverage, extract the exact production composition into a named helper and test that helper. Do not trade coverage shape for speed without preserving the behavior proof somewhere cheaper.
- Test performance guardrail: for plugin-owned static descriptors used by core tests or cold paths, prefer lightweight public artifacts with full-runtime fallback over loading broad bundled plugin barrels.
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
- For targeted/local debugging, use the native root-project entrypoint: `pnpm test <path-or-filter> [vitest args...]` (for example `pnpm test src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses the repo's default config/profile/pool routing.
- Do not set test workers above 16; tried already.
@@ -254,8 +250,8 @@
## Security & Configuration Tips
- Channel/provider state lives under `~/.openclaw/credentials/`; rerun `openclaw channels login` if logged out. Model auth profiles live under `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`; legacy OAuth import still reads `~/.openclaw/credentials/oauth.json`.
- Pi sessions live under `~/.openclaw/agents/<agentId>/sessions/` by default; `session.store` can override the session store path.
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook, `docs/reference/RELEASING.md` for the public release policy, and `$openclaw-release-maintainer` for the maintainership workflow.
@@ -272,13 +268,13 @@
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/openclaw && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway may run as an app-managed launchd job. Restart the gateway via the app or `openclaw gateway restart`; inspect with `openclaw gateway status --deep` or, for the default profile, `launchctl print gui/$UID/ai.openclaw.gateway`. Use `scripts/restart-mac.sh` when you need to rebuild/relaunch the local macOS app itself. The app LaunchAgent uses `ai.openclaw.mac`. **When debugging on macOS, start/stop the gateway via the app or gateway CLI, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the OpenClaw Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep openclaw` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the OpenClaw subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/version.json` (source for generated iOS config and Fastlane metadata), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), and `docs/install/updating.md` (pinned npm version).
- "Bump version everywhere" means all version locations above, then run `pnpm ios:version:sync` for iOS generated outputs. Only touch appcast metadata when cutting a new macOS Sparkle release.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- Mobile pairing: `ws://` (cleartext) is allowed for private LAN addresses (RFC 1918, link-local, mDNS `.local`) and loopback. Private LAN hosts typically lack PKI-backed identity, so requiring TLS there adds complexity without meaningful security gain. `wss://` is required for Tailscale and public endpoints.

View File

@@ -6,68 +6,8 @@ Docs: https://docs.openclaw.ai
### Changes
- macOS/gateway: add `screen.snapshot` support for macOS app nodes, including runtime plumbing, default macOS allowlisting, and docs for monitor preview flows. (#67954) Thanks @BunsDev.
### Fixes
- Agents/bootstrap: resolve bootstrap from workspace truth instead of stale session transcript markers, keep embedded bootstrap instructions on a hidden user-context prelude, suppress normal `/new` and `/reset` greetings while `BOOTSTRAP.md` is still pending, and make the embedded runner read the bootstrap ritual before replying normally.
- WhatsApp/multi-account: centralize named-account inbound policy, isolate per-account group activation and scoped session keys, preserve legacy activation backfill, and keep `accounts.default` shared defaults aligned across runtime, setup, and compat migration paths. Thanks @mcaxtr.
- Cron/delivery: clean up isolated sessions after direct deliveries when `deleteAfterRun` is enabled, covering structured and threaded branches that previously bypassed cleanup. (#67807) Thanks @MonkeyLeeT.
- Gateway/hello-ok: always report negotiated auth metadata for successful shared-auth handshakes, including control-ui bypass coverage when no device token is issued. (#67810) Thanks @BunsDev.
- Onboarding/non-interactive: preserve existing gateway auth tokens during re-onboard so active local gateway clients are not disconnected by an implicit token rotation. (#67821) Thanks @BKF-Gitty.
- OpenAI Codex/Responses: unify native Responses API capability detection so Codex OAuth requests emit the required `store: false` field on the native Responses path. (#67918) Thanks @obviyus.
- WhatsApp/setup: guard personal-phone and allowlist prompt values so setup fails with clear validation errors instead of crashing on undefined prompt text. (#67895) Thanks @lawrence3699.
- Models/config: preserve an existing `models.json` provider `baseUrl` during merge-mode regeneration so custom endpoints do not get reset on restart. (#67893) Thanks @lawrence3699.
- Plugins/discovery: reuse bundled and global plugin discovery results across workspace cache misses so Windows multi-workspace startup stops redoing the shared synchronous scan. (#67940) Thanks @obviyus.
- Plugins/webhooks: enforce synchronous plugin registration with full rollback of failed plugin side effects, and cache SecretRef-backed webhook auth per route so plugin startup and inbound webhook auth stay deterministic. (#67941) Thanks @obviyus.
- Telegram/ACP bindings: drop persisted DM bindings that still point at missing or failed ACP sessions on restart, while preserving plugin-owned bindings and uncertain store reads. (#67822) Thanks @chinar-amrutkar.
- Telegram/streaming: keep a transient preview on the same Telegram message when auto-compaction retries an in-flight answer, so streamed replies no longer appear duplicated after compaction. (#66939) Thanks @rubencu.
- Memory/sqlite-vec: emit the degraded sqlite-vec warning once per degraded episode instead of repeating it for every file write, while preserving the latch across safe-reindex rollback and resetting it when vector state is genuinely rebuilt. (#67898) Thanks @rubencu.
- Reply/block streaming: preserve post-stream incomplete-turn error payloads after block streaming already emitted content, so users get the warning instead of silence. (#67991) Thanks @obviyus.
- Telegram/streaming: clear the compaction replay guard after visible non-final boundaries so a post-tool assistant reply rotates to a fresh preview instead of editing the pre-compaction message. (#67993) Thanks @obviyus.
- Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras.
- macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner.
- macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199)
- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras.
- OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc.
- OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc.
- Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc.
- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc.
- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc.
- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc.
- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc.
- Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras.
- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201)
- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210)
- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke.
- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling.
- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195)
- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit.
- Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198)
- Telegram/streaming: fence same-session stale preview and finalization work after aborts so Telegram no longer replays an older reply or flushes a hidden short preview after the abort confirmation lands. (#68100) Thanks @rubencu.
- OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc.
- Config/redact: add `browser.cdpUrl` and `browser.profiles.*.cdpUrl` to sensitive URL config paths so embedded credentials (query tokens and HTTP Basic auth) are properly redacted in `config.get` API responses and availability error messages. (#67679) Thanks @Ziy1-Tan.
- Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699.
- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent.
- Matrix: honor `channels.matrix.network.dangerouslyAllowPrivateNetwork` when creating clients for private-network homeservers. (#68332) Thanks @kagura-agent.
- Cron/message tool: keep cron-owned runs with `delivery.mode: "none"` on the normal message-tool path so they can still send explicit messages, create threads, and route conditionally when no runner-owned delivery target is active. (#68482) Thanks @obviyus.
- Agents/failover: avoid treating bare leading `402 ...` prose as billing errors while still recognizing proxy subscription failures. (#45827) Thanks @junyuc25.
- Config/$schema: preserve root-authored `$schema` during partial config rewrites without injecting include-only schema URLs into the root config. (#47322) Thanks @EfeDurmaz16.
- Agents/CLI delivery: run the same reply-media path normalizer the auto-reply flow uses before shipping `openclaw agent --deliver` payloads, so relative `MEDIA:./out/photo.png` tokens resolve against the agent workspace instead of being rejected downstream with `LocalMediaAccessError: Local media path is not under an allowed directory`. Thanks @frankekn.
## 2026.4.15
### Changes
- Anthropic/models: default Anthropic selections, `opus` aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.
- Google/TTS: add Gemini text-to-speech support to the bundled `google` plugin, including provider registration, voice selection, WAV reply output, PCM telephony output, and setup/docs guidance. (#67515) Thanks @barronlroth.
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
- Memory/LanceDB: add cloud storage support to `memory-lancedb` so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.
- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.
- Agents/local models: add experimental `agents.defaults.experimental.localModelLean: true` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.
- Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.
- QA/Matrix: split Matrix live QA into a source-linked `qa-matrix` runner and keep repo-private `qa-*` surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.
- Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.
- Anthropic/models: default Anthropic selections, `opus` aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.
### Fixes
@@ -92,6 +32,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp/web-session: drain the pending per-auth creds save queue before reopening sockets so reconnect-time auth bootstrap no longer races in-flight `creds.json` writes and falsely restores from backup. (#67464) Thanks @neeravmakwana.
- BlueBubbles/catchup: add a per-message retry ceiling (`catchup.maxFailureRetries`, default 10) so a persistently-failing message with a malformed payload no longer wedges the catchup cursor forever. After N consecutive `processMessage` failures against the same GUID, catchup logs a WARN, skips that message on subsequent sweeps, and lets the cursor advance past it. Transient failures still retry from the same point as before. Also fixes a lost-update race in the persistent dedupe file lock that silently dropped inbound GUIDs on concurrent writes, a dedupe file naming migration gap on version upgrade, and a balloon-event bypass that let catchup replay debouncer-coalesced events as standalone messages. (#67426, #66870) Thanks @omarshahine.
- Ollama/chat: strip the `ollama/` provider prefix from Ollama chat request model ids so configured refs like `ollama/qwen3:14b-q8_0` stop 404ing against the Ollama API. (#67457) Thanks @suboss87.
- QA/Matrix: split the private QA lab runtime into smaller tested modules, add Matrix media contract coverage for image understanding and generated-image delivery, and update the memory-dreaming QA sweep to assert the separate phase-report layout. (#67430) Thanks @gumadeiras.
- Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so `~/...` host edit/write operations stop failing or reading back the wrong file when `OPENCLAW_HOME` differs. (#62804) Thanks @stainlu.
- Speech/TTS: auto-enable the bundled Microsoft and ElevenLabs speech providers, and route generic TTS directive tokens through the explicit or active provider first so overrides like `[[tts:speed=1.2]]` stop silently landing on the wrong provider. (#62846) Thanks @stainlu.
- OpenAI Codex/models: normalize stale native transport metadata in both runtime resolution and discovery/listing so legacy `openai-codex` rows with missing `api` or `https://chatgpt.com/backend-api/v1` self-heal to the canonical Codex transport instead of routing requests through broken HTML/Cloudflare paths, combining the original fixes proposed in #66969 (saamuelng601-pixel) and #67159 (hclsys). (#67635)
@@ -102,18 +43,19 @@ Docs: https://docs.openclaw.ai
- Extensions/lmstudio: add exponential backoff to the inference-preload wrapper so an LM Studio model-load failure (for example the built-in memory guardrail rejecting a load because the swap is saturated) no longer produces a WARN line every ~2s for every chat request. The wrapper now records consecutive preload failures per `(baseUrl, modelKey, contextLength)` tuple with a 5s → 10s → 20s → … → 5min cooldown and skips the preload step entirely while a cooldown is active, letting chat requests proceed directly to the stream (the model is often already loaded via the LM Studio UI). The combined `preload failed` log line now reports consecutive-failure count and remaining cooldown so operators can act on the real issue instead of drowning in repeated warnings. (#67401) Thanks @xantorres.
- Agents/replay: re-run tool/result pairing after strict replay tool-call ID sanitization on outbound requests so Anthropic-compatible providers like MiniMax no longer receive malformed orphan tool-result IDs such as `...toolresult1` during compaction and retry flows. (#67620) Thanks @stainlu.
- Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf
- Codex/harness: auto-enable the Codex plugin when `codex` is selected as an embedded agent harness runtime, including forced default, per-agent, and `OPENCLAW_AGENT_RUNTIME` paths. (#67474) Thanks @duqaXxX.
- OpenAI Codex/CLI: keep resumed `codex exec resume` runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported `--skip-git-repo-check` resume arg plus Codex's native `sandbox_mode="workspace-write"` config override. (#67666) Thanks @plgonzalezrx8.
- Codex/app-server: parse Desktop-originated app-server user agents such as `Codex Desktop/0.118.0`, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf.
- Cron/announce delivery: keep isolated announce `NO_REPLY` stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale `NO_REPLY` text. (#65016) Thanks @BKF-Gitty.
- Sessions/Codex: skip redundant `delivery-mirror` transcript appends only when the latest assistant message has the same visible text, preventing duplicate visible replies on Codex-backed turns without suppressing repeated answers across turns. (#67185) Thanks @andyylin.
- Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT.
- BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept `updated-message` webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine.
- Agents/skills: sort prompt-facing `available_skills` entries by skill name after merging sources so `skills.load.extraDirs` order no longer changes prompt-cache prefixes. (#64198) Thanks @Bartok9.
- Agents/OpenAI Responses: add `models.providers.*.models.*.compat.supportsPromptCacheKey` so OpenAI-compatible proxies that forward `prompt_cache_key` can keep prompt caching enabled while incompatible endpoints can still force stripping. (#67427) Thanks @damselem.
- Agents/context engines: keep loop-hook and final `afterTurn` prompt-cache touch metadata aligned with the current assistant turn so cache-aware context engines retain accurate cache TTL state during tool loops. (#67767) thanks @jalehman.
- Memory/dreaming: strip AI-facing inbound metadata envelopes from session-corpus user turns before normalization so REM topic extraction sees the user's actual message text, including array-shaped split envelopes. (#66548) Thanks @zqchris.
- Agents/errors: detect standalone Cloudflare/CDN HTML challenge pages before transport DNS classification so provider block pages no longer appear as local DNS lookup failures. (#67704) Thanks @chris-yyau.
## 2026.4.15-beta.1
### Changes
- Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new `models.authStatus` gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.
- Memory/LanceDB: add cloud storage support to `memory-lancedb` so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.
- GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.
- Agents/local models: add experimental `agents.defaults.experimental.localModelLean: true` to drop heavyweight default tools like `browser`, `cron`, and `message`, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.
- Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.
### Fixes
- Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)
- CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)
- CLI/update: prune stale packaged `dist` chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.
@@ -145,7 +87,7 @@ Docs: https://docs.openclaw.ai
- Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so `.epub` and `.mobi` uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-`text/plain` coercion. (#66877) Thanks @martinfrancois.
- Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when `commands.native` and `commands.nativeSkills` stay on `auto`. (#66843) Thanks @kashevk0.
- OpenRouter/Qwen3: parse `reasoning_details` stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.
- BlueBubbles/catchup: replay missed webhook messages after gateway restart via a persistent per-account cursor and `/api/v1/message/query?after=<ts>` pass, so messages delivered while the gateway was down no longer disappear. Uses the existing `processMessage` path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.
- fix(bluebubbles): replay missed webhook messages after gateway restart via a persistent per-account cursor and `/api/v1/message/query?after=<ts>` pass, so messages delivered while the gateway was down no longer disappear. Uses the existing `processMessage` path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.
- Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.
- Audio/self-hosted STT: restore `models.providers.*.request.allowPrivateNetwork` for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.
- Auto-reply/media: allow workspace-rooted absolute media paths in auto-reply send flows so valid local media references no longer fail path validation. (#66689)
@@ -157,11 +99,18 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.
- Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to `.csv` or `.md` slip past the host-read guard. (#67047) Thanks @Unayung.
- Ollama/onboarding: split setup into `Cloud + Local`, `Cloud only`, and `Local only`, support direct `OLLAMA_API_KEY` cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.
- Docker/build: verify `@matrix-org/matrix-sdk-crypto-nodejs` native bindings with `find` under `node_modules` instead of a hardcoded `.pnpm/...` path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) Thanks @ly85206559.
- Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring `channels.matrix.password`, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.
- Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with `NO_REPLY` so trailing silent sentinels no longer leak summary text to the target channel. (#65004) Thanks @neo1027144-creator.
- Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so `OPENCLAW_BUNDLED_PLUGINS_DIR` flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.
- Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) Thanks @gumadeiras.
- Agents/context + Memory: trim default startup/skills prompt budgets, cap `memory_get` excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.
- Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.
- Matrix/security: block DM pairing-store entries from authorizing room control commands. (#67294) Thanks @pgondhi987.
- Gateway/security: enforce `localRoots` containment on the webchat audio embedding path. (#67298) Thanks @pgondhi987.
- Webchat/security: reject remote-host `file://` URLs in the media embedding path. (#67293) Thanks @pgondhi987.
- Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment `dailyCount` across days instead of stalling at `1`. (#67091) Thanks @Bartok9.
- Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like `/usr/bin/whoami` no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.
- Codex/gateway: fix gateway crash when the codex-acp subprocess terminates abruptly; an unhandled EPIPE on the child stdin stream now routes through graceful client shutdown, rejecting pending requests instead of propagating as an uncaught exception that crashes the entire gateway daemon and all connected channels. Fixes #67886. (#67947) thanks @openperf
- Slack/streaming: resolve native streaming recipient teams from the inbound user when available, with a monitor-team fallback, so DM and shared-workspace streams target the right recipient more reliably.
## 2026.4.14

View File

@@ -59,7 +59,7 @@ Welcome to the lobster tank! 🦞
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
- GitHub [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
- **Josh Lehman** - Compaction, Context Engine
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
- GitHub [@jalehman](https://github.com/jalehman) · X: [@jlehman\_](https://x.com/jlehman_)
- **Radek Sienkiewicz** - Docs, Control UI

View File

@@ -2,122 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.4.15</title>
<pubDate>Thu, 16 Apr 2026 23:33:29 +0000</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>2026041590</sparkle:version>
<sparkle:shortVersionString>2026.4.15</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.4.15</h2>
<h3>Changes</h3>
<ul>
<li>Anthropic/models: default Anthropic selections, <code>opus</code> aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.</li>
<li>Google/TTS: add Gemini text-to-speech support to the bundled <code>google</code> plugin, including provider registration, voice selection, WAV reply output, PCM telephony output, and setup/docs guidance. (#67515) Thanks @barronlroth.</li>
<li>Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new <code>models.authStatus</code> gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.</li>
<li>Memory/LanceDB: add cloud storage support to <code>memory-lancedb</code> so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.</li>
<li>GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.</li>
<li>Agents/local models: add experimental <code>agents.defaults.experimental.localModelLean: true</code> to drop heavyweight default tools like <code>browser</code>, <code>cron</code>, and <code>message</code>, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.</li>
<li>Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.</li>
<li>QA/Matrix: split Matrix live QA into a source-linked <code>qa-matrix</code> runner and keep repo-private <code>qa-*</code> surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.</li>
<li>Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Gateway/tools: anchor trusted local <code>MEDIA:</code> tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (<code>400 invalid_request_error</code> on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303)</li>
<li>Agents/replay recovery: classify the provider wording <code>401 input item ID does not belong to this connection</code> as replay-invalid, so users get the existing <code>/new</code> session reset guidance instead of a raw 401-style failure. (#66475) Thanks @dallylee.</li>
<li>Gateway/webchat: enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.</li>
<li>Matrix/pairing: block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.</li>
<li>Docker/build: verify <code>@matrix-org/matrix-sdk-crypto-nodejs</code> native bindings with <code>find</code> under <code>node_modules</code> instead of a hardcoded <code>.pnpm/...</code> path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.</li>
<li>Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring <code>channels.matrix.password</code>, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.</li>
<li>Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with <code>NO_REPLY</code> so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.</li>
<li>Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so <code>OPENCLAW_BUNDLED_PLUGINS_DIR</code> flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.</li>
<li>Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras.</li>
<li>Agents/context + Memory: trim default startup/skills prompt budgets, cap <code>memory_get</code> excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.</li>
<li>Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.</li>
<li>Memory-core/dreaming: skip dreaming narrative transcripts from session-store metadata before bootstrap records land so dream diary prompt/prose lines do not pollute session ingestion. (#67315) thanks @jalehman.</li>
<li>Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when <code>agents.defaults.contextTokens</code> is the real limit. (#66236) Thanks @ImLukeF.</li>
<li>Dreaming/memory-core: change the default <code>dreaming.storage.mode</code> from <code>inline</code> to <code>separate</code> so Dreaming phase blocks (<code>## Light Sleep</code>, <code>## REM Sleep</code>) land in <code>memory/dreaming/{phase}/YYYY-MM-DD.md</code> instead of being injected into <code>memory/YYYY-MM-DD.md</code>. Daily memory files no longer get dominated by structured candidate output, and the daily-ingestion scanner that already strips dream marker blocks no longer has to compete with hundreds of phase-block lines on every run. Operators who want the previous behavior can opt in by setting <code>plugins.entries.memory-core.config.dreaming.storage.mode: "inline"</code>. (#66412) Thanks @mjamiv.</li>
<li>Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine.</li>
<li>Dashboard: constrain exec approval modal overflow on desktop so long command content no longer pushes action buttons out of view. (#67082) Thanks @Ziy1-Tan.</li>
<li>Agents/CLI transcripts: persist successful CLI-backed turns into the OpenClaw session transcript so google-gemini-cli replies appear in session history and the Control UI again. (#67490) Thanks @obviyus.</li>
<li>Discord/tool-call text: strip standalone Gemma-style <code><function>...</function></code> tool-call payloads from visible assistant text without truncating prose examples or trailing replies. (#67318) Thanks @joelnishanth.</li>
<li>WhatsApp/web-session: drain the pending per-auth creds save queue before reopening sockets so reconnect-time auth bootstrap no longer races in-flight <code>creds.json</code> writes and falsely restores from backup. (#67464) Thanks @neeravmakwana.</li>
<li>BlueBubbles/catchup: add a per-message retry ceiling (<code>catchup.maxFailureRetries</code>, default 10) so a persistently-failing message with a malformed payload no longer wedges the catchup cursor forever. After N consecutive <code>processMessage</code> failures against the same GUID, catchup logs a WARN, skips that message on subsequent sweeps, and lets the cursor advance past it. Transient failures still retry from the same point as before. Also fixes a lost-update race in the persistent dedupe file lock that silently dropped inbound GUIDs on concurrent writes, a dedupe file naming migration gap on version upgrade, and a balloon-event bypass that let catchup replay debouncer-coalesced events as standalone messages. (#67426, #66870) Thanks @omarshahine.</li>
<li>Ollama/chat: strip the <code>ollama/</code> provider prefix from Ollama chat request model ids so configured refs like <code>ollama/qwen3:14b-q8_0</code> stop 404ing against the Ollama API. (#67457) Thanks @suboss87.</li>
<li>Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so <code>~/...</code> host edit/write operations stop failing or reading back the wrong file when <code>OPENCLAW_HOME</code> differs. (#62804) Thanks @stainlu.</li>
<li>Speech/TTS: auto-enable the bundled Microsoft and ElevenLabs speech providers, and route generic TTS directive tokens through the explicit or active provider first so overrides like <code>[[tts:speed=1.2]]</code> stop silently landing on the wrong provider. (#62846) Thanks @stainlu.</li>
<li>OpenAI Codex/models: normalize stale native transport metadata in both runtime resolution and discovery/listing so legacy <code>openai-codex</code> rows with missing <code>api</code> or <code>https://chatgpt.com/backend-api/v1</code> self-heal to the canonical Codex transport instead of routing requests through broken HTML/Cloudflare paths, combining the original fixes proposed in #66969 (saamuelng601-pixel) and #67159 (hclsys). (#67635)</li>
<li>Agents/failover: treat HTML provider error pages as upstream transport failures for CDN-style 5xx responses without misclassifying embedded body text as API rate limits, while still preserving auth remediation for HTML 401/403 pages and proxy remediation for HTML 407 pages. (#67642) Thanks @stainlu.</li>
<li>Gateway/skills: bump the cached skills-snapshot version whenever a config write touches <code>skills.*</code> (for example <code>skills.allowBundled</code>, <code>skills.entries.<id>.enabled</code>, or <code>skills.profile</code>). Existing agent sessions persist a <code>skillsSnapshot</code> in <code>sessions.json</code> that reuses the skill list frozen at session creation; without this invalidation, removing a bundled skill from the allowlist left the old snapshot live and the model kept calling the disabled tool, producing <code>Tool <name> not found</code> loops that ran until the embedded-run timeout. (#67401) Thanks @xantorres.</li>
<li>Agents/tool-loop: enable the unknown-tool stream guard by default. Previously <code>resolveUnknownToolGuardThreshold</code> returned <code>undefined</code> unless <code>tools.loopDetection.enabled</code> was explicitly set to <code>true</code>, which left the protection off in the default configuration. A hallucinated or removed tool (for example <code>himalaya</code> after it was dropped from <code>skills.allowBundled</code>) would then loop "Tool X not found" attempts until the full embedded-run timeout. The guard has no false-positive surface because it only triggers on tools that are objectively not registered in the run, so it now stays on regardless of <code>tools.loopDetection.enabled</code> and still accepts <code>tools.loopDetection.unknownToolThreshold</code> as a per-run override (default 10). (#67401) Thanks @xantorres.</li>
<li>TUI/streaming: add a client-side streaming watchdog to <code>tui-event-handlers</code> so the <code>streaming · Xm Ys</code> activity indicator resets to <code>idle</code> after 30s of delta silence on the active run. Guards against lost or late <code>state: "final"</code> chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on <code>streaming</code> indefinitely; a new system log line surfaces the reset so users know to send a new message to resync. The window is configurable via the new <code>streamingWatchdogMs</code> context option (set to <code>0</code> to disable), and the handler now exposes a <code>dispose()</code> that clears the pending timer on shutdown. (#67401) Thanks @xantorres.</li>
<li>Extensions/lmstudio: add exponential backoff to the inference-preload wrapper so an LM Studio model-load failure (for example the built-in memory guardrail rejecting a load because the swap is saturated) no longer produces a WARN line every ~2s for every chat request. The wrapper now records consecutive preload failures per <code>(baseUrl, modelKey, contextLength)</code> tuple with a 5s → 10s → 20s → … → 5min cooldown and skips the preload step entirely while a cooldown is active, letting chat requests proceed directly to the stream (the model is often already loaded via the LM Studio UI). The combined <code>preload failed</code> log line now reports consecutive-failure count and remaining cooldown so operators can act on the real issue instead of drowning in repeated warnings. (#67401) Thanks @xantorres.</li>
<li>Agents/replay: re-run tool/result pairing after strict replay tool-call ID sanitization on outbound requests so Anthropic-compatible providers like MiniMax no longer receive malformed orphan tool-result IDs such as <code>...toolresult1</code> during compaction and retry flows. (#67620) Thanks @stainlu.</li>
<li>Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf</li>
<li>Codex/harness: auto-enable the Codex plugin when <code>codex</code> is selected as an embedded agent harness runtime, including forced default, per-agent, and <code>OPENCLAW_AGENT_RUNTIME</code> paths. (#67474) Thanks @duqaXxX.</li>
<li>OpenAI Codex/CLI: keep resumed <code>codex exec resume</code> runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported <code>--skip-git-repo-check</code> resume arg plus Codex's native <code>sandbox_mode="workspace-write"</code> config override. (#67666) Thanks @plgonzalezrx8.</li>
<li>Codex/app-server: parse Desktop-originated app-server user agents such as <code>Codex Desktop/0.118.0</code>, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf.</li>
<li>Cron/announce delivery: keep isolated announce <code>NO_REPLY</code> stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale <code>NO_REPLY</code> text. (#65016) Thanks @BKF-Gitty.</li>
<li>Sessions/Codex: skip redundant <code>delivery-mirror</code> transcript appends only when the latest assistant message has the same visible text, preventing duplicate visible replies on Codex-backed turns without suppressing repeated answers across turns. (#67185) Thanks @andyylin.</li>
<li>Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT.</li>
<li>BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept <code>updated-message</code> webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine.</li>
<li>Agents/skills: sort prompt-facing <code>available_skills</code> entries by skill name after merging sources so <code>skills.load.extraDirs</code> order no longer changes prompt-cache prefixes. (#64198) Thanks @Bartok9.</li>
<li>Agents/OpenAI Responses: add <code>models.providers.*.models.*.compat.supportsPromptCacheKey</code> so OpenAI-compatible proxies that forward <code>prompt_cache_key</code> can keep prompt caching enabled while incompatible endpoints can still force stripping. (#67427) Thanks @damselem.</li>
<li>Agents/context engines: keep loop-hook and final <code>afterTurn</code> prompt-cache touch metadata aligned with the current assistant turn so cache-aware context engines retain accurate cache TTL state during tool loops. (#67767) thanks @jalehman.</li>
<li>Memory/dreaming: strip AI-facing inbound metadata envelopes from session-corpus user turns before normalization so REM topic extraction sees the user's actual message text, including array-shaped split envelopes. (#66548) Thanks @zqchris.</li>
<li>Agents/errors: detect standalone Cloudflare/CDN HTML challenge pages before transport DNS classification so provider block pages no longer appear as local DNS lookup failures. (#67704) Thanks @chris-yyau.</li>
<li>Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)</li>
<li>CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)</li>
<li>CLI/update: prune stale packaged <code>dist</code> chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.</li>
<li>Onboarding/CLI: fix channel-selection crashes on globally installed CLI setups during onboarding. (#66736)</li>
<li>Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.</li>
<li>Memory-core/QMD <code>memory_get</code>: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (<code>MEMORY.md</code>, <code>memory.md</code>, <code>DREAMS.md</code>, <code>dreams.md</code>, <code>memory/**</code>) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses <code>read</code> tool-policy denials. (#66026) Thanks @eleqtrizit.</li>
<li>Cron/agents: forward embedded-run tool policy and internal event params into the attempt layer so <code>--tools</code> allowlists, cron-owned message-tool suppression, explicit message targeting, and command-path internal events all take effect at runtime again. (#62675) Thanks @hexsprite.</li>
<li>Setup/providers: guard preferred-provider lookup during setup so malformed plugin metadata with a missing provider id no longer crashes the wizard with <code>Cannot read properties of undefined (reading 'trim')</code>. (#66649) Thanks @Tianworld.</li>
<li>Matrix/security: normalize sandboxed profile avatar params, preserve <code>mxc://</code> avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.</li>
<li>Telegram/documents: drop leaked binary caption bytes from inbound Telegram text handling so document uploads like <code>.mobi</code> or <code>.epub</code> no longer explode prompt token counts. (#66663) Thanks @joelnishanth.</li>
<li>Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via <code>getResolvedAuth()</code>, mirroring the WebSocket path, so a secret rotated through <code>secrets.reload</code> or config hot-reload stops authenticating on <code>/v1/*</code>, <code>/tools/invoke</code>, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps.</li>
<li>Agents/compaction: cap the compaction reserve-token floor to the model context window so small-context local models (e.g. Ollama with 16K tokens) no longer trigger context-overflow errors or infinite compaction loops on every prompt. (#65671) Thanks @openperf.</li>
<li>Agents/OpenAI Responses: classify the exact <code>Unknown error (no error details in response)</code> transport failure as failover reason <code>unknown</code> so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer.</li>
<li>Models/probe: surface invalid-model probe failures as <code>format</code> instead of <code>unknown</code> in <code>models list --probe</code>, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi.</li>
<li>Agents/failover: classify OpenAI-compatible <code>finish_reason: network_error</code> stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.</li>
<li>Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.</li>
<li>Slack/native commands: fix option menus for slash commands such as <code>/verbose</code> when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared <code>openclaw_cmdarg*</code> listener. Thanks @Wangmerlyn.</li>
<li>Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing <code>encryptKey</code> and blank callback tokens — refuse to start the webhook transport without an <code>encryptKey</code>, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.</li>
<li>Agents/workspace files: route <code>agents.files.get</code>, <code>agents.files.set</code>, and workspace listing through the shared <code>fs-safe</code> helpers (<code>openFileWithinRoot</code>/<code>readFileWithinRoot</code>/<code>writeFileWithinRoot</code>), reject symlink aliases for allowlisted agent files, and have <code>fs-safe</code> resolve opened-file real paths from the file descriptor before falling back to path-based <code>realpath</code> so a symlink swap between <code>open</code> and <code>realpath</code> can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.</li>
<li>Gateway/MCP loopback: switch the <code>/mcp</code> bearer comparison from plain <code>!==</code> to constant-time <code>safeEqualSecret</code> (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via <code>checkBrowserOrigin</code> before the auth gate runs. Loopback origins (<code>127.0.0.1:*</code>, <code>localhost:*</code>, same-origin) still go through, including the <code>localhost</code>↔<code>127.0.0.1</code> host mismatch that browsers flag as <code>Sec-Fetch-Site: cross-site</code>. (#66665) Thanks @eleqtrizit.</li>
<li>Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit.</li>
<li>Agents/fallback: preserve the original prompt body on model fallback retries with session history so the retrying model keeps the active task instead of only seeing a generic continue message. (#66029) Thanks @WuKongAI-CMU.</li>
<li>Reply/secrets: resolve active reply channel/account SecretRefs before reply-run message-action discovery so channel token SecretRefs (for example Discord) do not degrade into discovery-time unresolved-secret failures. (#66796) Thanks @joshavant.</li>
<li>Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid <code>max_tokens</code> values no longer reach the provider API. (#66664) thanks @jalehman</li>
<li>Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman.</li>
<li>BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine.</li>
<li>Secrets/plugins/status: align SecretRef inspect-vs-strict handling across plugin preload, read-only status/agents surfaces, and runtime auth paths so unresolved refs no longer crash read-only CLI flows while runtime-required non-env refs stay strict. (#66818) Thanks @joshavant.</li>
<li>Memory/dreaming: stop ordinary transcripts that merely quote the dream-diary prompt from being classified as internal dreaming runs and silently dropped from session recall ingestion. (#66852) Thanks @gumadeiras.</li>
<li>Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so <code>.epub</code> and <code>.mobi</code> uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-<code>text/plain</code> coercion. (#66877) Thanks @martinfrancois.</li>
<li>Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when <code>commands.native</code> and <code>commands.nativeSkills</code> stay on <code>auto</code>. (#66843) Thanks @kashevk0.</li>
<li>OpenRouter/Qwen3: parse <code>reasoning_details</code> stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.</li>
<li>BlueBubbles/catchup: replay missed webhook messages after gateway restart via a persistent per-account cursor and <code>/api/v1/message/query?after=<ts></code> pass, so messages delivered while the gateway was down no longer disappear. Uses the existing <code>processMessage</code> path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.</li>
<li>Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.</li>
<li>Audio/self-hosted STT: restore <code>models.providers.*.request.allowPrivateNetwork</code> for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.</li>
<li>Auto-reply/media: allow workspace-rooted absolute media paths in auto-reply send flows so valid local media references no longer fail path validation. (#66689)</li>
<li>WhatsApp/Baileys media upload: harden encrypted upload handling so large outbound media sends avoid buffer spikes and reliability regressions. (#65966) Thanks @frankekn.</li>
<li>QQBot/cron: guard against undefined <code>event.content</code> in <code>parseFaceTags</code> and <code>filterInternalMarkers</code> so cron-triggered agent turns with no content payload no longer crash with <code>TypeError: Cannot read properties of undefined (reading 'startsWith')</code>. (#66302) Thanks @xinmotlanthua.</li>
<li>CLI/plugins: stop <code>--dangerously-force-unsafe-install</code> plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.</li>
<li>Claude CLI/sessions: classify <code>No conversation found with session ID</code> as <code>session_expired</code> so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn.</li>
<li>Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf.</li>
<li>Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.</li>
<li>Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to <code>.csv</code> or <code>.md</code> slip past the host-read guard. (#67047) Thanks @Unayung.</li>
<li>Ollama/onboarding: split setup into <code>Cloud + Local</code>, <code>Cloud only</code>, and <code>Local only</code>, support direct <code>OLLAMA_API_KEY</code> cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.</li>
<li>Webchat/security: reject remote-host <code>file://</code> URLs in the media embedding path. (#67293) Thanks @pgondhi987.</li>
<li>Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment <code>dailyCount</code> across days instead of stalling at <code>1</code>. (#67091) Thanks @Bartok9.</li>
<li>Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like <code>/usr/bin/whoami</code> no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.15/OpenClaw-2026.4.15.zip" length="47501638" type="application/octet-stream" sparkle:edSignature="JUG3cicpJqCQDvp7VYoN6qBuN4Kn4s0+QQFjlMR69OZlwViLdiStPIHa+1vpuoR4miYhJc9knSDVCFzSfQuYCQ=="/>
</item>
<item>
<title>2026.4.14</title>
<pubDate>Tue, 14 Apr 2026 14:08:09 +0000</pubDate>

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026041690
versionName = "2026.4.16"
versionCode = 2026041501
versionName = "2026.4.15-beta.1"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -1,9 +1,5 @@
# OpenClaw iOS Changelog
## 2026.4.16 - 2026-04-17
Maintenance update for the current OpenClaw release.
## 2026.4.15 - 2026-04-15
Maintenance update for the current OpenClaw beta release.

View File

@@ -2,8 +2,8 @@
// Source of truth: apps/ios/version.json
// Generated by scripts/ios-sync-versioning.ts.
OPENCLAW_IOS_VERSION = 2026.4.16
OPENCLAW_MARKETING_VERSION = 2026.4.16
OPENCLAW_IOS_VERSION = 2026.4.15
OPENCLAW_MARKETING_VERSION = 2026.4.15
OPENCLAW_BUILD_VERSION = 1
#include? "../build/Version.xcconfig"

View File

@@ -1 +1 @@
Maintenance update for the current OpenClaw release.
Maintenance update for the current OpenClaw beta release.

View File

@@ -1,3 +1,3 @@
{
"version": "2026.4.16"
"version": "2026.4.15"
}

View File

@@ -3,12 +3,6 @@ import Foundation
enum CommandResolver {
private static let projectRootDefaultsKey = "openclaw.gatewayProjectRootPath"
private static let helperName = "openclaw"
static let strictHostKeyCheckingSSHOptions = [
"-o", "StrictHostKeyChecking=yes",
]
static let updateHostKeysSSHOptions = [
"-o", "UpdateHostKeys=yes",
]
static func gatewayEntrypoint(in root: URL) -> String? {
let distEntry = root.appendingPathComponent("dist/index.js").path
@@ -403,7 +397,9 @@ enum CommandResolver {
"""
let options: [String] = [
"-o", "BatchMode=yes",
] + self.strictHostKeyCheckingSSHOptions + self.updateHostKeysSSHOptions
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = self.sshArguments(
target: parsed,
identity: settings.identity,

View File

@@ -22,21 +22,7 @@ enum ExecApprovalCommandDisplaySanitizer {
}
private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool {
let category = scalar.properties.generalCategory
if category == .control
|| category == .format
|| category == .lineSeparator
|| category == .paragraphSeparator
{
return true
}
// Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so
// attackers cannot spoof token boundaries in the approval UI with spaces that render
// like a plain space but are handled differently by shells/parsers.
if category == .spaceSeparator, scalar.value != 0x20 {
return true
}
return self.invisibleCodePoints.contains(scalar.value)
scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value)
}
private static func escape(_ scalar: UnicodeScalar) -> String {

View File

@@ -146,7 +146,6 @@ final class MacNodeModeCoordinator {
OpenClawCanvasA2UICommand.push.rawValue,
OpenClawCanvasA2UICommand.pushJSONL.rawValue,
OpenClawCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.snapshot.rawValue,
MacNodeScreenCommand.record.rawValue,
OpenClawSystemCommand.notify.rawValue,
OpenClawSystemCommand.which.rawValue,

View File

@@ -63,8 +63,6 @@ actor MacNodeRuntime {
return try await self.handleCameraInvoke(req)
case OpenClawLocationCommand.get.rawValue:
return try await self.handleLocationInvoke(req)
case MacNodeScreenCommand.snapshot.rawValue:
return try await self.handleScreenSnapshotInvoke(req)
case MacNodeScreenCommand.record.rawValue:
return try await self.handleScreenRecordInvoke(req)
case OpenClawSystemCommand.run.rawValue:
@@ -354,34 +352,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleScreenSnapshotInvoke(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = (try? Self.decodeParams(MacNodeScreenSnapshotParams.self, from: req.paramsJSON)) ??
MacNodeScreenSnapshotParams()
let services = await self.mainActorServices()
let capturedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
let res = try await services.snapshotScreen(
screenIndex: params.screenIndex,
maxWidth: params.maxWidth,
quality: params.quality,
format: params.format)
struct ScreenSnapshotPayload: Encodable {
var format: String
var base64: String
var width: Int
var height: Int
var screenIndex: Int?
var capturedAtMs: Int64
}
let payload = try Self.encodePayload(ScreenSnapshotPayload(
format: res.format.rawValue,
base64: res.data.base64EncodedString(),
width: res.width,
height: res.height,
screenIndex: params.screenIndex,
capturedAtMs: capturedAtMs))
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func mainActorServices() async -> any MacNodeRuntimeMainActorServices {
if let cachedMainActorServices { return cachedMainActorServices }
let services = await self.makeMainActorServices()

View File

@@ -4,13 +4,6 @@ import OpenClawKit
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -28,24 +21,9 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
@MainActor
final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
private let screenSnapshotter = ScreenSnapshotService()
private let screenRecorder = ScreenRecordService()
private let locationService = MacNodeLocationService()
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
try await self.screenSnapshotter.snapshot(
screenIndex: screenIndex,
maxWidth: maxWidth,
quality: quality,
format: format)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,

View File

@@ -1,18 +1,9 @@
import Foundation
import OpenClawKit
enum MacNodeScreenCommand: String, Codable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
struct MacNodeScreenSnapshotParams: Codable, Equatable {
var screenIndex: Int?
var maxWidth: Int?
var quality: Double?
var format: OpenClawScreenSnapshotFormat?
}
struct MacNodeScreenRecordParams: Codable, Equatable {
var screenIndex: Int?
var durationMs: Int?

View File

@@ -483,7 +483,8 @@ final class NodePairingApprovalPrompter {
"-o", "ConnectTimeout=5",
"-o", "NumberOfPasswordPrompts=0",
"-o", "PreferredAuthentications=publickey",
] + CommandResolver.strictHostKeyCheckingSSHOptions
"-o", "StrictHostKeyChecking=accept-new",
]
guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
return false
}

View File

@@ -200,7 +200,9 @@ enum RemoteGatewayProbe {
let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
let args = CommandResolver.sshArguments(
target: parsed,
identity: identity,

View File

@@ -73,12 +73,14 @@ final class RemotePortTunnel {
let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "TCPKeepAlive=yes",
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
] + CommandResolver.strictHostKeyCheckingSSHOptions + CommandResolver.updateHostKeysSSHOptions
]
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
let args = CommandResolver.sshArguments(
target: parsed,

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.4.16</string>
<string>2026.4.15-beta.1</string>
<key>CFBundleVersion</key>
<string>2026041690</string>
<string>2026041501</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -1,109 +0,0 @@
import AppKit
import Foundation
import OpenClawKit
@preconcurrency import ScreenCaptureKit
@MainActor
final class ScreenSnapshotService {
enum ScreenSnapshotError: LocalizedError {
case noDisplays
case invalidScreenIndex(Int)
case captureFailed(String)
case encodeFailed(String)
var errorDescription: String? {
switch self {
case .noDisplays:
"No displays available for screen snapshot"
case let .invalidScreenIndex(idx):
"Invalid screen index \(idx)"
case let .captureFailed(message):
message
case let .encodeFailed(message):
message
}
}
}
func snapshot(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
let format = format ?? .jpeg
let normalized = Self.normalize(maxWidth: maxWidth, quality: quality, format: format)
let content = try await SCShareableContent.current
let displays = content.displays.sorted { $0.displayID < $1.displayID }
guard !displays.isEmpty else {
throw ScreenSnapshotError.noDisplays
}
let idx = screenIndex ?? 0
guard idx >= 0, idx < displays.count else {
throw ScreenSnapshotError.invalidScreenIndex(idx)
}
let display = displays[idx]
let filter = SCContentFilter(display: display, excludingWindows: [])
let config = SCStreamConfiguration()
let targetSize = Self.targetSize(
width: display.width,
height: display.height,
maxWidth: normalized.maxWidth)
config.width = targetSize.width
config.height = targetSize.height
config.showsCursor = true
let cgImage: CGImage
do {
cgImage = try await SCScreenshotManager.captureImage(
contentFilter: filter,
configuration: config)
} catch {
throw ScreenSnapshotError.captureFailed(error.localizedDescription)
}
let bitmap = NSBitmapImageRep(cgImage: cgImage)
let data: Data
switch format {
case .png:
guard let encoded = bitmap.representation(using: .png, properties: [:]) else {
throw ScreenSnapshotError.encodeFailed("png encode failed")
}
data = encoded
case .jpeg:
guard let encoded = bitmap.representation(
using: .jpeg,
properties: [.compressionFactor: normalized.quality])
else {
throw ScreenSnapshotError.encodeFailed("jpeg encode failed")
}
data = encoded
}
return (data: data, format: format, width: cgImage.width, height: cgImage.height)
}
private static func normalize(
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat)
-> (maxWidth: Int, quality: Double)
{
let resolvedMaxWidth = maxWidth.flatMap { $0 > 0 ? $0 : nil } ?? (format == .png ? 900 : 1600)
let resolvedQuality = min(1.0, max(0.05, quality ?? 0.72))
return (maxWidth: resolvedMaxWidth, quality: resolvedQuality)
}
private static func targetSize(width: Int, height: Int, maxWidth: Int) -> (width: Int, height: Int) {
guard width > 0, height > 0, width > maxWidth else {
return (width: width, height: height)
}
let scale = Double(maxWidth) / Double(width)
let targetHeight = max(1, Int((Double(height) * scale).rounded()))
return (width: maxWidth, height: targetHeight)
}
}

View File

@@ -164,9 +164,6 @@ import Testing
} else {
#expect(Bool(false))
}
#expect(cmd.contains("StrictHostKeyChecking=yes"))
#expect(!cmd.contains("StrictHostKeyChecking=accept-new"))
#expect(cmd.contains("UpdateHostKeys=yes"))
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {

View File

@@ -9,37 +9,4 @@ struct ExecApprovalCommandDisplaySanitizerTests {
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
"date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가")
}
@Test func `escapes control characters used to spoof line breaks`() {
let input = "echo safe\n\rcurl https://example.test"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(input) ==
"echo safe\\u{A}\\u{D}curl https://example.test")
}
@Test func `escapes Unicode line and paragraph separators`() {
let lineInput = "echo ok\u{2028}curl https://example.test"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) ==
"echo ok\\u{2028}curl https://example.test")
let paragraphInput = "echo ok\u{2029}curl https://example.test"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) ==
"echo ok\\u{2029}curl https://example.test")
}
@Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() {
let nbspInput = "echo ok\u{00A0}curl"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl")
let narrowNbspInput = "echo ok\u{202F}curl"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl")
let ideographicSpaceInput = "echo ok\u{3000}curl"
#expect(
ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) ==
"echo ok\\u{3000}curl")
let asciiSpaceInput = "echo ok curl"
#expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl")
}
}

View File

@@ -78,19 +78,6 @@ struct MacNodeRuntimeTests {
@Test func `handle invoke screen record uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
_ = screenIndex
_ = maxWidth
_ = quality
return (Data("snapshot".utf8), format ?? .jpeg, 640, 360)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
@@ -140,94 +127,6 @@ struct MacNodeRuntimeTests {
#expect(!payload.base64.isEmpty)
}
@Test func `handle invoke screen snapshot uses injected services`() async throws {
@MainActor
final class FakeMainActorServices: MacNodeRuntimeMainActorServices, @unchecked Sendable {
var snapshotCalledAtMs: Int64?
func snapshotScreen(
screenIndex: Int?,
maxWidth: Int?,
quality: Double?,
format: OpenClawScreenSnapshotFormat?) async throws
-> (data: Data, format: OpenClawScreenSnapshotFormat, width: Int, height: Int)
{
self.snapshotCalledAtMs = Int64(Date().timeIntervalSince1970 * 1000)
#expect(screenIndex == 0)
#expect(maxWidth == 800)
#expect(quality == 0.5)
return (Data("ok".utf8), format ?? .jpeg, 800, 450)
}
func recordScreen(
screenIndex: Int?,
durationMs: Int?,
fps: Double?,
includeAudio: Bool?,
outPath: String?) async throws -> (path: String, hasAudio: Bool)
{
let url = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-test-screen-record-\(UUID().uuidString).mp4")
try Data("ok".utf8).write(to: url)
return (path: url.path, hasAudio: false)
}
func locationAuthorizationStatus() -> CLAuthorizationStatus {
.authorizedAlways
}
func locationAccuracyAuthorization() -> CLAccuracyAuthorization {
.fullAccuracy
}
func currentLocation(
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
{
_ = desiredAccuracy
_ = maxAgeMs
_ = timeoutMs
return CLLocation(latitude: 0, longitude: 0)
}
}
let services = await MainActor.run { FakeMainActorServices() }
let runtime = MacNodeRuntime(makeMainActorServices: { services })
let params = MacNodeScreenSnapshotParams(
screenIndex: 0,
maxWidth: 800,
quality: 0.5,
format: .jpeg)
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
let response = await runtime.handleInvoke(
BridgeInvokeRequest(
id: "req-screen-snapshot",
command: MacNodeScreenCommand.snapshot.rawValue,
paramsJSON: json))
#expect(response.ok == true)
let payloadJSON = try #require(response.payloadJSON)
struct Payload: Decodable {
var format: String
var base64: String
var width: Int
var height: Int
var capturedAtMs: Int64
}
let payload = try JSONDecoder().decode(Payload.self, from: Data(payloadJSON.utf8))
#expect(payload.format == "jpeg")
#expect(payload.base64 == Data("ok".utf8).base64EncodedString())
#expect(payload.width == 800)
#expect(payload.height == 450)
#expect(payload.capturedAtMs > 0)
let snapshotCalledAtMs = await MainActor.run { services.snapshotCalledAtMs }
#expect(snapshotCalledAtMs != nil)
#expect(payload.capturedAtMs <= snapshotCalledAtMs!)
}
@Test func `handle invoke browser proxy uses injected request`() async {
let runtime = MacNodeRuntime(browserProxyRequest: { paramsJSON in
#expect(paramsJSON?.contains("/tabs") == true)

View File

@@ -444,18 +444,34 @@ private struct ChatComposerTextView: NSViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
guard let composerTextView = textView as? ChatComposerNSTextView else {
preconditionFailure("ChatComposerTextViewFactory must return ChatComposerNSTextView")
}
composerTextView.delegate = context.coordinator
let textView = ChatComposerNSTextView()
textView.delegate = context.coordinator
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
composerTextView.string = self.text
composerTextView.onSend = { [weak composerTextView] in
composerTextView?.window?.makeFirstResponder(nil)
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
textView.string = self.text
textView.onSend = { [weak textView] in
textView?.window?.makeFirstResponder(nil)
self.onSend()
}
composerTextView.onPasteImageAttachment = self.onPasteImageAttachment
textView.onPasteImageAttachment = self.onPasteImageAttachment
let scroll = NSScrollView()
scroll.drawsBackground = false
@@ -506,34 +522,6 @@ private struct ChatComposerTextView: NSViewRepresentable {
}
}
enum ChatComposerTextViewFactory {
// Internal for @testable import coverage of composer text view defaults.
@MainActor
static func makeConfiguredTextView() -> NSTextView {
let textView = ChatComposerNSTextView()
textView.drawsBackground = false
textView.isRichText = false
textView.isAutomaticQuoteSubstitutionEnabled = false
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticDashSubstitutionEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 4)
textView.focusRingType = .none
textView.allowsUndo = true
textView.minSize = .zero
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.autoresizingMask = [.width]
textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
return textView
}
}
private final class ChatComposerNSTextView: NSTextView {
var onSend: (() -> Void)?
var onPasteImageAttachment: ((_ data: Data, _ fileName: String, _ mimeType: String) -> Void)?

View File

@@ -1,34 +1,9 @@
import Foundation
public enum OpenClawScreenCommand: String, Codable, Sendable {
case snapshot = "screen.snapshot"
case record = "screen.record"
}
public enum OpenClawScreenSnapshotFormat: String, Codable, Sendable {
case jpeg
case png
}
public struct OpenClawScreenSnapshotParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var maxWidth: Int?
public var quality: Double?
public var format: OpenClawScreenSnapshotFormat?
public init(
screenIndex: Int? = nil,
maxWidth: Int? = nil,
quality: Double? = nil,
format: OpenClawScreenSnapshotFormat? = nil)
{
self.screenIndex = screenIndex
self.maxWidth = maxWidth
self.quality = quality
self.format = format
}
}
public struct OpenClawScreenRecordParams: Codable, Sendable, Equatable {
public var screenIndex: Int?
public var durationMs: Int?

View File

@@ -1,15 +0,0 @@
#if os(macOS)
import AppKit
import Testing
@testable import OpenClawChatUI
@Suite
@MainActor
struct ChatComposerTextViewTests {
@Test func configuredComposerTextViewEnablesUndo() {
let textView = ChatComposerTextViewFactory.makeConfiguredTextView()
#expect(textView.allowsUndo)
}
}
#endif

View File

@@ -1,4 +1,4 @@
17d43e3c5dd6ac09fa6c7954e6923d2e0fba767483bac0a2b257fb7ec736f8a4 config-baseline.json
fdb7867bbc18792d3645ea36c31b64425d6f19c0b19a7460564f67eb97c0a71e config-baseline.core.json
99bb34fcf83ba6bb50a3fc11f170bd379bee5728b0938707fc39ebd7638e12eb config-baseline.channel.json
b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json
4fec95c9ce02dddb4d3021812cf68df8b4cc92c5ba4db35778bb1bfe6fa63021 config-baseline.json
aafbb407e62908709e90f750ea0f8274016fcfcbd613394896ff984f967f236e config-baseline.core.json
ef83a06633fc001b5b2535566939186ecb49d05cd1a90b40e54cc58d3e6e44e3 config-baseline.channel.json
5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
d08f0e793e66192fdbc377183ce0d94adcbec53cf334522bce8c0c457b90b0a8 plugin-sdk-api-baseline.json
924f20b350a9f1997e95b3d7249cbb6720c9576c63e6c0c15cca0164734fd93d plugin-sdk-api-baseline.jsonl
c2c6319c35f152d2a2b36584981b92c22f7e9759a27d47ad66bfdbcef916eace plugin-sdk-api-baseline.json
3ba23b54667c75caba3560cc66a399b7bdd9b316009bf5ad6a43aefd469f1552 plugin-sdk-api-baseline.jsonl

View File

@@ -207,6 +207,10 @@
"source": "Release Policy",
"target": "发布策略"
},
{
"source": "Testing CI Policy",
"target": "测试 CI 策略"
},
{
"source": "Release policy",
"target": "发布策略"

View File

@@ -496,7 +496,11 @@
},
{
"group": "发布策略",
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
"pages": [
"zh-CN/reference/RELEASING",
"zh-CN/reference/testing-ci-policy",
"zh-CN/reference/test"
]
}
]
},

View File

@@ -120,8 +120,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into
the session and continues. Large bootstrap files are truncated when injected;
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and
`agents.defaults.bootstrapTotalMaxChars` (default: 60000).
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and
`agents.defaults.bootstrapTotalMaxChars` (default: 150000).
`openclaw setup` can recreate missing defaults without overwriting existing
files.

View File

@@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and whats in your workspace.
```
🧠 Context breakdown
Workspace: <workspaceDir>
Bootstrap max/file: 12,000 chars
Bootstrap max/file: 20,000 chars
Sandbox: mode=non-main sandboxed=false
System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok))
@@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
- `HEARTBEAT.md`
- `BOOTSTRAP.md` (first-run only)
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`).

View File

@@ -62,10 +62,7 @@ That lane provisions a disposable Tuwunel homeserver in Docker, registers
temporary driver, SUT, and observer users, creates one private room, then runs
the real Matrix plugin inside a QA gateway child. The live transport lane keeps
the child config scoped to the transport under test, so Matrix runs without
`qa-channel` in the child config. It writes the structured report artifacts and
a combined stdout/stderr log into the selected Matrix QA output directory. To
capture the outer `scripts/run-node.mjs` build/launcher output too, set
`OPENCLAW_RUN_NODE_OUTPUT_LOG=<path>` to a repo-local log file.
`qa-channel` in the child config.
For a transport-real Telegram smoke lane, run:
@@ -120,7 +117,7 @@ can write back through the mounted workspace.
Seed assets live in `qa/`:
- `qa/scenarios/index.md`
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/*.md`
These are intentionally in git so the QA plan is visible to both humans and the
agent.
@@ -129,7 +126,6 @@ agent.
the source of truth for one test run and should define:
- scenario metadata
- optional category, capability, lane, and risk metadata
- docs and code refs
- optional plugin requirements
- optional gateway config patch
@@ -140,10 +136,6 @@ and cross-cutting. For example, markdown scenarios can combine transport-side
helpers with browser-side helpers that drive the embedded Control UI through the
Gateway `browser.request` seam without adding a special-case runner.
Scenario files should be grouped by product capability rather than source tree
folder. Keep scenario IDs stable when files move; use `docsRefs` and `codeRefs`
for implementation traceability.
The baseline list should stay broad enough to cover:
- DM and channel chat
@@ -156,22 +148,6 @@ The baseline list should stay broad enough to cover:
- repo-reading and docs-reading
- one small build task such as Lobster Invaders
## Provider mock lanes
`qa suite` has two local provider mock lanes:
- `mock-openai` is the scenario-aware OpenClaw mock. It remains the default
deterministic mock lane for repo-backed QA and parity gates.
- `aimock` starts an AIMock-backed provider server for experimental protocol,
fixture, record/replay, and chaos coverage. It is additive and does not
replace the `mock-openai` scenario dispatcher.
Provider-lane implementation lives under `extensions/qa-lab/src/providers/`.
Each provider owns its defaults, local server startup, gateway model config,
auth-profile staging needs, and live/mock capability flags. Shared suite and
gateway code should route through the provider registry instead of branching on
provider names.
## Transport adapters
`qa-lab` owns a generic transport seam for markdown QA scenarios.

View File

@@ -118,9 +118,9 @@ unexpectedly high context usage and more frequent compaction.
> as a one-shot startup-context block for that first turn.
Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
(default: 60000). Missing files inject a short missing-file marker. When truncation
(default: 150000). Missing files inject a short missing-file marker. When truncation
occurs, OpenClaw can inject a warning block in Project Context; control this with
`agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`;
default: `once`).

View File

@@ -1573,7 +1573,7 @@
},
{
"group": "Release policy",
"pages": ["reference/RELEASING", "reference/test"]
"pages": ["reference/RELEASING", "reference/testing-ci-policy", "reference/test"]
}
]
},

View File

@@ -221,7 +221,7 @@ The bundled OpenAI plugin also registers a default for `codex-cli`:
- `command: "codex"`
- `args: ["exec","--json","--color","never","--sandbox","workspace-write","--skip-git-repo-check"]`
- `resumeArgs: ["exec","resume","{sessionId}","-c","sandbox_mode=\"workspace-write\"","--skip-git-repo-check"]`
- `resumeArgs: ["exec","resume","{sessionId}","--color","never","--sandbox","workspace-write","--skip-git-repo-check"]`
- `output: "jsonl"`
- `resumeOutput: "text"`
- `modelArg: "--model"`

View File

@@ -955,21 +955,21 @@ Controls when workspace bootstrap files are injected into the system prompt. Def
### `agents.defaults.bootstrapMaxChars`
Max characters per workspace bootstrap file before truncation. Default: `12000`.
Max characters per workspace bootstrap file before truncation. Default: `20000`.
```json5
{
agents: { defaults: { bootstrapMaxChars: 12000 } },
agents: { defaults: { bootstrapMaxChars: 20000 } },
}
```
### `agents.defaults.bootstrapTotalMaxChars`
Max total characters injected across all workspace bootstrap files. Default: `60000`.
Max total characters injected across all workspace bootstrap files. Default: `150000`.
```json5
{
agents: { defaults: { bootstrapTotalMaxChars: 60000 } },
agents: { defaults: { bootstrapTotalMaxChars: 150000 } },
}
```

View File

@@ -89,21 +89,7 @@ Gateway → Client:
```
`server`, `features`, `snapshot`, and `policy` are all required by the schema
(`src/gateway/protocol/schema/frames.ts`). `canvasHostUrl` is optional. `auth`
reports the negotiated role/scopes when available, and includes `deviceToken`
when the gateway issues one.
When no device token is issued, `hello-ok.auth` can still report the negotiated
permissions:
```json
{
"auth": {
"role": "operator",
"scopes": ["operator.read", "operator.write"]
}
}
```
(`src/gateway/protocol/schema/frames.ts`). `auth` and `canvasHostUrl` are optional.
When a device token is issued, `hello-ok` also includes:

View File

@@ -52,10 +52,6 @@ These commands sit beside the main test suites when you need QA-lab realism:
gateway workers, up to 64 workers or the selected scenario count. Use
`--concurrency <count>` to tune the worker count, or `--concurrency 1` for
the older serial lane.
- Supports provider modes `live-frontier`, `mock-openai`, and `aimock`.
`aimock` starts a local AIMock-backed provider server for experimental
fixture and protocol-mock coverage without replacing the scenario-aware
`mock-openai` lane.
- `pnpm openclaw qa suite --runner multipass`
- Runs the same QA suite inside a disposable Multipass Linux VM.
- Keeps the same scenario-selection behavior as `qa suite` on the host.
@@ -69,9 +65,6 @@ These commands sit beside the main test suites when you need QA-lab realism:
`.artifacts/qa-e2e/...`.
- `pnpm qa:lab:up`
- Starts the Docker-backed QA site for operator-style QA work.
- `pnpm openclaw qa aimock`
- Starts only the local AIMock provider server for direct protocol smoke
testing.
- `pnpm openclaw qa matrix`
- Runs the Matrix live QA lane against a disposable Docker-backed Tuwunel homeserver.
- This QA host is repo/dev-only today. Packaged OpenClaw installs do not ship
@@ -81,7 +74,7 @@ These commands sit beside the main test suites when you need QA-lab realism:
- Provisions three temporary Matrix users (`driver`, `sut`, `observer`) plus one private room, then starts a QA gateway child with the real Matrix plugin as the SUT transport.
- Uses the pinned stable Tuwunel image `ghcr.io/matrix-construct/tuwunel:v1.5.1` by default. Override with `OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE` when you need to test a different image.
- Matrix does not expose shared credential-source flags because the lane provisions disposable users locally.
- Writes a Matrix QA report, summary, observed-events artifact, and combined stdout/stderr output log under `.artifacts/qa-e2e/...`.
- Writes a Matrix QA report, summary, and observed-events artifact under `.artifacts/qa-e2e/...`.
- `pnpm openclaw qa telegram`
- Runs the Telegram live QA lane against a real private group using the driver and SUT bot tokens from env.
- Requires `OPENCLAW_QA_TELEGRAM_GROUP_ID`, `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`, and `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`. The group id must be the numeric Telegram chat id.
@@ -213,7 +206,7 @@ The minimum adoption bar for a new channel is:
4. Mount the runner as `openclaw qa <runner>` instead of registering a competing root command.
Runner plugins should declare `qaRunners` in `openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations` array from `runtime-api.ts`.
Keep `runtime-api.ts` light; lazy CLI and runner execution should stay behind separate entrypoints.
5. Author or adapt markdown scenarios under the themed `qa/scenarios/` directories.
5. Author or adapt markdown scenarios under `qa/scenarios/`.
6. Use the generic scenario helpers for new scenarios.
7. Keep existing compatibility aliases working unless the repo is doing an intentional migration.
@@ -355,6 +348,8 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Catch provider format changes, tool-calling quirks, auth issues, and rate limit behavior
- Expectations:
- Not CI-stable by design (real networks, real provider policies, quotas, outages)
- That usually means "run this in release or scheduled CI instead of PR CI"
- Do not read this as "only run it by hand"
- Costs money / uses rate limits
- Prefer running narrowed subsets instead of “everything”
- Live runs source `~/.profile` to pick up missing API keys.
@@ -853,6 +848,10 @@ These Docker runners split into two buckets:
explicitly want the larger exhaustive scan.
- `test:docker:all` builds the live Docker image once via `test:docker:live-build`, then reuses it for the two live Docker lanes.
- Container smoke runners: `test:docker:openwebui`, `test:docker:onboard`, `test:docker:gateway-network`, `test:docker:mcp-channels`, and `test:docker:plugins` boot one or more real containers and verify higher-level integration paths.
- Use [Testing CI Policy](/reference/testing-ci-policy) to decide whether a
runner belongs in `PR CI`, `release CI`, `scheduled CI`, or `manual only`.
The important part is this: a suite can be required in CI even when it does
not block every PR or live in the publish workflow.
The live-model Docker runners also bind-mount only the needed CLI auth homes (or all supported ones when the run is not narrowed), then copy them into the container home before the run so external-CLI OAuth can refresh tokens without mutating the host auth store:
@@ -890,6 +889,9 @@ This lane expects a usable live model key, and `OPENCLAW_PROFILE_FILE`
(`~/.profile` by default) is the primary way to provide it in Dockerized runs.
Successful runs print a small JSON payload like `{ "ok": true, "model":
"openclaw/default", ... }`.
Keep this in release or scheduled CI if it matters for the product surface you
are changing. Being slower than a normal PR lane is not a reason to quietly
drop it to manual-only.
`test:docker:mcp-channels` is intentionally deterministic and does not need a
real Telegram, Discord, or iMessage account. It boots a seeded Gateway
container, starts a second container that spawns `openclaw mcp serve`, then

View File

@@ -55,7 +55,7 @@ The macOS app presents itself as a node. Common commands:
- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
- Camera: `camera.snap`, `camera.clip`
- Screen: `screen.snapshot`, `screen.record`
- Screen: `screen.record`
- System: `system.run`, `system.notify`
The node reports a `permissions` map so agents can decide whats allowed.

View File

@@ -185,9 +185,7 @@ Keep inbound mention handling split in two layers:
- plugin-owned evidence gathering
- shared policy evaluation
Use `openclaw/plugin-sdk/channel-mention-gating` for mention-policy decisions.
Use `openclaw/plugin-sdk/channel-inbound` only when you need the broader inbound
helper barrel.
Use `openclaw/plugin-sdk/channel-inbound` for the shared layer.
Good fit for plugin-local logic:
@@ -257,11 +255,6 @@ bundled channel plugins that already depend on runtime injection:
- `implicitMentionKindWhen`
- `resolveInboundMentionDecision`
If you only need `implicitMentionKindWhen` and
`resolveInboundMentionDecision`, import from
`openclaw/plugin-sdk/channel-mention-gating` to avoid loading unrelated inbound
runtime helpers.
The older `resolveMentionGating*` helpers remain on
`openclaw/plugin-sdk/channel-inbound` as compatibility exports only. New code
should use `resolveInboundMentionDecision({ facts, policy })`.

View File

@@ -318,7 +318,7 @@ Current bundled provider examples:
| `plugin-sdk/memory-core` | Bundled memory-core helpers | Memory manager/config/file/CLI helper surface |
| `plugin-sdk/memory-core-engine-runtime` | Memory engine runtime facade | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory embedding contracts, registry access, local provider, and generic batch/remote helpers; concrete remote providers live in their owning plugins |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine | Memory host embedding engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers | Memory host multimodal helpers |

View File

@@ -88,7 +88,6 @@ explicitly promotes one as public.
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter` |
| `plugin-sdk/channel-config-schema` | Channel config schema types |
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink` |
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
@@ -96,7 +95,6 @@ explicitly promotes one as public.
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
| `plugin-sdk/outbound-runtime` | Outbound identity/send delegate helpers |
| `plugin-sdk/poll-runtime` | Narrow poll normalization helpers |
| `plugin-sdk/thread-bindings-runtime` | Thread-binding lifecycle and adapter helpers |
| `plugin-sdk/agent-media-payload` | Legacy agent media payload builder |
| `plugin-sdk/conversation-runtime` | Conversation/thread binding, pairing, and configured-binding helpers |
@@ -110,10 +108,7 @@ explicitly promotes one as public.
| `plugin-sdk/group-access` | Shared group-access decision helpers |
| `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers |
| `plugin-sdk/interactive-runtime` | Interactive reply payload normalization/reduction helpers |
| `plugin-sdk/channel-inbound` | Compatibility barrel for inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-mention-gating` | Narrow mention-policy helpers without the broader inbound runtime surface |
| `plugin-sdk/channel-location` | Channel location context and formatting helpers |
| `plugin-sdk/channel-logging` | Channel logging helpers for inbound drops and typing/ack failures |
| `plugin-sdk/channel-inbound` | Inbound debounce, mention matching, mention-policy helpers, and envelope helpers |
| `plugin-sdk/channel-send-result` | Reply result types |
| `plugin-sdk/channel-actions` | `createMessageToolButtonsSchema`, `createMessageToolCardSchema` |
| `plugin-sdk/channel-targets` | Target parsing/matching helpers |
@@ -171,7 +166,6 @@ explicitly promotes one as public.
| `plugin-sdk/secret-ref-runtime` | Narrow `coerceSecretRef` and SecretRef typing helpers for secret-contract/config parsing |
| `plugin-sdk/security-runtime` | Shared trust, DM gating, external-content, and secret-collection helpers |
| `plugin-sdk/ssrf-policy` | Host allowlist and private-network SSRF policy helpers |
| `plugin-sdk/ssrf-dispatcher` | Narrow pinned-dispatcher helpers without the broad infra runtime surface |
| `plugin-sdk/ssrf-runtime` | Pinned-dispatcher, SSRF-guarded fetch, and SSRF policy helpers |
| `plugin-sdk/secret-input` | Secret input parsing helpers |
| `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
@@ -193,7 +187,6 @@ explicitly promotes one as public.
| `plugin-sdk/gateway-runtime` | Gateway client and channel-status patch helpers |
| `plugin-sdk/config-runtime` | Config load/write helpers |
| `plugin-sdk/telegram-command-config` | Telegram command-name/description normalization and duplicate/conflict checks, even when the bundled Telegram contract surface is unavailable |
| `plugin-sdk/text-autolink-runtime` | File-reference autolink detection without the broad text-runtime barrel |
| `plugin-sdk/approval-runtime` | Exec/plugin approval helpers, approval-capability builders, auth/profile helpers, native routing/runtime helpers |
| `plugin-sdk/reply-runtime` | Shared inbound/reply runtime helpers, chunking, dispatch, heartbeat, reply planner |
| `plugin-sdk/reply-dispatch-runtime` | Narrow reply dispatch/finalize helpers |
@@ -218,7 +211,6 @@ explicitly promotes one as public.
| `plugin-sdk/file-lock` | Re-entrant file-lock helpers |
| `plugin-sdk/persistent-dedupe` | Disk-backed dedupe cache helpers |
| `plugin-sdk/acp-runtime` | ACP runtime/session and reply-dispatch helpers |
| `plugin-sdk/acp-binding-resolve-runtime` | Read-only ACP binding resolution without lifecycle startup imports |
| `plugin-sdk/agent-config-primitives` | Narrow agent runtime config-schema primitives |
| `plugin-sdk/boolean-param` | Loose boolean param reader |
| `plugin-sdk/dangerous-name-runtime` | Dangerous-name matching resolution helpers |
@@ -234,12 +226,6 @@ explicitly promotes one as public.
| `plugin-sdk/diagnostic-runtime` | Diagnostic flag and event helpers |
| `plugin-sdk/error-runtime` | Error graph, formatting, shared error classification helpers, `isApprovalNotFoundError` |
| `plugin-sdk/fetch-runtime` | Wrapped fetch, proxy, and pinned lookup helpers |
| `plugin-sdk/runtime-fetch` | Dispatcher-aware runtime fetch without proxy/guarded-fetch imports |
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/session-store-runtime` | Session-store read helpers without broad config writes/maintenance imports |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |
| `plugin-sdk/host-runtime` | Hostname and SCP host normalization helpers |
| `plugin-sdk/retry-runtime` | Retry config and retry runner helpers |
| `plugin-sdk/agent-runtime` | Agent dir/identity/workspace helpers |
@@ -278,7 +264,7 @@ explicitly promotes one as public.
| `plugin-sdk/memory-core` | Bundled memory-core helper surface for manager/config/file/CLI helpers |
| `plugin-sdk/memory-core-engine-runtime` | Memory index/search runtime facade |
| `plugin-sdk/memory-core-host-engine-foundation` | Memory host foundation engine exports |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding contracts, registry access, local provider, and generic batch/remote helpers |
| `plugin-sdk/memory-core-host-engine-embeddings` | Memory host embedding engine exports |
| `plugin-sdk/memory-core-host-engine-qmd` | Memory host QMD engine exports |
| `plugin-sdk/memory-core-host-engine-storage` | Memory host storage engine exports |
| `plugin-sdk/memory-core-host-multimodal` | Memory host multimodal helpers |

View File

@@ -18,7 +18,7 @@ The desired end state is a generic QA harness that loads powerful scenario defin
## Current State
Primary source of truth now lives in `qa/scenarios/index.md` plus one file per
scenario under `qa/scenarios/<theme>/*.md`.
scenario under `qa/scenarios/*.md`.
Implemented:
@@ -26,7 +26,7 @@ Implemented:
- canonical QA pack metadata
- operator identity
- kickoff mission
- `qa/scenarios/<theme>/*.md`
- `qa/scenarios/*.md`
- one markdown file per scenario
- scenario metadata
- handler bindings
@@ -107,8 +107,8 @@ These categories matter because they drive DSL requirements. A flat list of prom
### Single source of truth
Use `qa/scenarios/index.md` plus `qa/scenarios/<theme>/*.md` as the authored
source of truth.
Use `qa/scenarios/index.md` plus `qa/scenarios/*.md` as the authored source of
truth.
The pack should stay:
@@ -363,7 +363,7 @@ Generated compatibility:
Done.
- added `qa/scenarios/index.md`
- split scenarios into `qa/scenarios/<theme>/*.md`
- split scenarios into `qa/scenarios/*.md`
- added parser for named markdown YAML pack content
- validated with zod
- switched consumers to the parsed pack

View File

@@ -51,6 +51,15 @@ OpenClaw has three public release lanes:
- This split is intentional: keep the real npm release path short,
deterministic, and artifact-focused, while slower live checks stay in their
own lane so they do not stall or block publish
- Use [Testing CI Policy](/reference/testing-ci-policy) as the source of truth
for which end-to-end and live suites belong in `PR CI`, `release CI`,
`scheduled CI`, or `manual only`.
- Read that split literally:
- the publish workflow is the short path that prepares and promotes artifacts
- other important end-to-end checks can still be required CI in release or
scheduled workflows
- In other words, "not in the publish workflow" does not mean "manual only."
It often means "run it in a different CI lane."
- Release checks must be dispatched from the `main` workflow ref so the
workflow logic and secrets stay canonical
- That workflow accepts either an existing release tag or the current full

View File

@@ -8,6 +8,7 @@ title: "Tests"
# Tests
- Full testing kit (suites, live, Docker): [Testing](/help/testing)
- CI placement for end-to-end and live suites: [Testing CI Policy](/reference/testing-ci-policy)
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
@@ -28,9 +29,9 @@ title: "Tests"
- `pnpm test:perf:profile:main`: writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`).
- `pnpm test:perf:profile:runner`: writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`).
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. This is normal CI coverage, not just a local debugging command.
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. These tests are often too noisy or expensive for PR CI, but that usually means "release or scheduled CI," not "skip CI."
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`) and pulls an external Open WebUI image. Treat it as a release or scheduled CI compatibility check rather than as a blocking PR lane.
- `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits.
## Local PR gate

View File

@@ -0,0 +1,82 @@
---
summary: "Source of truth for where end-to-end and live tests belong in CI"
read_when:
- Deciding whether an end-to-end or live suite belongs in CI
- Adding or moving Docker, release, or live-provider coverage
title: "Testing CI Policy"
---
# Testing CI Policy
This page is the source of truth for where OpenClaw end-to-end and live suites
belong.
Use this page to answer one practical question: when we have a real-world test,
where should it run?
Work through the questions in this order:
1. Do we need this test to protect users from a real regression?
2. If yes, where should it run: on PRs, before releases, on a schedule, or
only by hand?
3. If it runs in CI, should it fail the lane or just report problems?
The mistake to avoid is simple: a test can be important enough to run in CI
without being important enough to block every PR or to sit inside the publish
workflow.
Example:
- A live provider test may be too slow, flaky, or expensive for normal PR CI.
- That does not make it a manual-only test.
- It usually means the test belongs in release CI or scheduled CI instead.
## CI lanes
- `PR CI`: runs on pull requests or push validation when the touched surface
needs it. Use this for fast, high-signal checks that should catch regressions
before merge.
- `Release CI`: runs before a release in a dedicated workflow lane. It may be
blocking or non-blocking, but it is still required CI. Use this for important
install, upgrade, compatibility, and provider checks that are too heavy for
normal PR workflows.
- `Scheduled CI`: runs on a timer or on-demand to catch drift in providers,
third-party integrations, or long-running compatibility paths. Use this when
you want ongoing coverage but do not want every PR or release to wait on it.
- `Manual only`: keep for debug, hardware-specific, or operator-driven VM work.
Do not put a suite here just because it is slower than a unit test.
## End-to-end and live matrix
| Suite | What it proves | Expected CI lane | Blocking guidance |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
| `pnpm test` | Core unit, integration, and routed repo test coverage | `PR CI` | Blocking |
| `pnpm test:install:smoke` | Install script smoke plus packed tarball size checks | `PR CI` when relevant; `release CI` before tags | Blocking |
| `pnpm test:e2e` | Real gateway WS/HTTP/node pairing behavior | `PR CI` when gateway or pairing changes | Blocking when relevant |
| `pnpm test:docker:onboard` | Interactive onboarding wizard, config creation, gateway startup, health | `PR CI` when onboarding/setup changes; otherwise `release CI` | Blocking when relevant |
| `pnpm test:docker:gateway-network` | Two-container gateway auth and health path | `PR CI` when gateway/network transport changes; otherwise `release CI` | Blocking when relevant |
| `pnpm test:docker:mcp-channels` | Real `openclaw mcp serve` bridge, routing, transcripts, notifications | `PR CI` when MCP/channel bridge surfaces change; otherwise `release CI` | Blocking when relevant |
| `pnpm test:docker:plugins` | Plugin install, `/plugin` alias behavior, restart semantics | `PR CI` when plugin runtime or install surfaces change; otherwise `release CI` | Blocking when relevant |
| `pnpm test:docker:doctor-switch` | Repair and daemon switching between git and npm installs | `release CI` | Blocking for release work that touches install or doctor flows |
| `pnpm test:docker:qr` | QR runtime compatibility under supported Docker Node versions | `release CI` | Usually non-blocking, but still required CI |
| `pnpm test:install:e2e` | Full installer path with real onboarding-style flow in Docker | `release CI` | Required CI; may live outside the publish workflow |
| `OpenClaw Cross-OS Release Checks` workflow | Fresh install, packaged upgrade, installer fresh, dev update across macOS, Windows, Linux | `release CI` | Required CI; keep separate from the publish workflow |
| Native Discord roundtrip in cross-OS release checks | Real Discord send/readback after install or update | `release CI` | Usually non-blocking, but still required CI when enabled |
| `pnpm test:docker:openwebui` | OpenClaw behind Open WebUI with a real proxied chat | `release CI` and `scheduled CI` | Non-blocking is fine; do not drop it from CI |
| `pnpm test:live` | Real provider/model behavior with live credentials | `scheduled CI` and `release CI` when provider risk matters | Non-blocking is fine; do not make "not CI-stable" mean manual-only |
| `pnpm test:docker:live-models` and `pnpm test:docker:live-gateway` | Live provider coverage inside repo Docker images | `scheduled CI` and `release CI` when provider/gateway risk matters | Non-blocking is fine |
| `pnpm test:docker:live-cli-backend` | Real CLI backend compatibility inside Docker | `scheduled CI` | Non-blocking is fine |
| `pnpm test:docker:live-acp-bind` | ACP bind compatibility against real agent backends | `scheduled CI` | Non-blocking is fine |
| `pnpm test:docker:live-codex-harness` | Codex app-server harness compatibility | `scheduled CI` | Non-blocking is fine |
| `test:parallels:*` | VM-specific host/guest install and upgrade smoke | `manual only` unless a dedicated VM CI lane exists | Manual/operator lane |
## Change policy
When you add or move an end-to-end or live suite:
1. Update this matrix in the same PR.
2. Update the owning workflow or add the missing lane.
3. Update any release or maintainer docs that point to the suite.
If current workflows lag behind this matrix, treat that as follow-up work to
close rather than as permission to quietly downgrade the suite to manual-only.

View File

@@ -82,10 +82,7 @@ html.dark .nav-tabs-underline {
border-radius: 8px;
background: color-mix(in oklab, rgb(var(--primary)) 4%, transparent);
text-decoration: none;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease;
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.showcase-actions a:first-child {

View File

@@ -15,14 +15,12 @@ title: "Thinking Levels"
- low → “think hard”
- medium → “think harder”
- high → “ultrathink” (max budget)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models and Anthropic Claude Opus 4.7 effort)
- adaptive → provider-managed adaptive thinking (supported for Anthropic Claude 4.6 and Opus 4.7)
- xhigh → “ultrathink+” (GPT-5.2 + Codex models only)
- adaptive → provider-managed adaptive reasoning budget (supported for Anthropic Claude 4.6 model family)
- `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`.
- `highest`, `max` map to `high`.
- Provider notes:
- Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set.
- Anthropic Claude Opus 4.7 does not default to adaptive thinking. Its API effort default remains provider-owned unless you explicitly set a thinking level.
- Anthropic Claude Opus 4.7 maps `/think xhigh` to adaptive thinking plus `output_config.effort: "xhigh"`, because `/think` is a thinking directive and `xhigh` is the Opus 4.7 effort setting.
- MiniMax (`minimax/*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from MiniMax's non-native Anthropic stream format.
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
@@ -33,7 +31,7 @@ title: "Thinking Levels"
2. Session override (set by sending a directive-only message).
3. Per-agent default (`agents.list[].thinkingDefault` in config).
4. Global default (`agents.defaults.thinkingDefault` in config).
5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `off` for Anthropic Claude Opus 4.7 unless explicitly configured, `low` for other reasoning-capable models, `off` otherwise.
5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
## Setting a session default
@@ -106,9 +104,8 @@ title: "Thinking Levels"
- The web chat thinking selector mirrors the session's stored level from the inbound session store/config when the page loads.
- Picking another level writes the session override immediately via `sessions.patch`; it does not wait for the next send and it is not a one-shot `thinkingOnce` override.
- The first option is always `Default (<resolved level>)`, where the resolved default comes from the active session model: `adaptive` for Claude 4.6 on Anthropic, `off` for Anthropic Claude Opus 4.7 unless configured, `low` for other reasoning-capable models, `off` otherwise.
- The first option is always `Default (<resolved level>)`, where the resolved default comes from the active session model: `adaptive` for Claude 4.6 on Anthropic/Bedrock, `low` for other reasoning-capable models, `off` otherwise.
- The picker stays provider-aware:
- most providers show `off | minimal | low | medium | high | adaptive`
- Anthropic Claude Opus 4.7 shows `off | minimal | low | medium | high | xhigh | adaptive`
- Z.AI shows binary `off | on`
- `/think:<level>` still works and updates the same stored session level, so chat directives and the picker stay in sync.

View File

@@ -60,10 +60,6 @@ third-party plugins see.
- Do not rely on eager global registry seeding or import-time side effects to
make a plugin “available”. Plugin availability should come from manifest
ownership plus targeted activation.
- When core needs plugin-owned static data on a hot path, expose a lightweight
top-level artifact such as `gateway-auth-api.ts`, `message-tool-api.ts`, or a
similarly narrow `*-api.ts`. Reuse the same local helper from the artifact and
the full plugin so fast paths do not drift from runtime behavior.
## Expanding The Boundary

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/acpx",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"description": "OpenClaw ACP runtime backend",
"type": "module",
"dependencies": {

View File

@@ -119,7 +119,7 @@ describe("active-memory plugin", () => {
runEmbeddedPiAgent.mockResolvedValue({
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
});
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
});
afterEach(async () => {
@@ -425,7 +425,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
allowedChatTypes: ["direct", "group"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },
@@ -513,7 +513,7 @@ describe("active-memory plugin", () => {
searchMode: "inherit",
},
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -602,7 +602,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -630,7 +630,7 @@ describe("active-memory plugin", () => {
queryMode: "message",
promptStyle: "preference-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -675,7 +675,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
thinking: "medium",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -701,7 +701,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
promptAppend: "Prefer stable long-term preferences over one-off events.",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -730,7 +730,7 @@ describe("active-memory plugin", () => {
promptOverride: "Custom memory prompt. Return NONE or one user fact.",
promptAppend: "Extra custom instruction.",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -802,7 +802,7 @@ describe("active-memory plugin", () => {
api.pluginConfig = {
agents: ["main"],
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? temp transcript", messages: [] },
@@ -828,7 +828,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
modelFallbackPolicy: "resolved-only",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? no fallback", messages: [] },
@@ -851,7 +851,7 @@ describe("active-memory plugin", () => {
modelFallback: "google/gemini-3-flash",
modelFallbackPolicy: "default-remote",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? custom fallback", messages: [] },
@@ -878,7 +878,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
modelFallbackPolicy: "default-remote",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order? built-in fallback", messages: [] },
@@ -1027,7 +1027,7 @@ describe("active-memory plugin", () => {
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
let lastAbortSignal: AbortSignal | undefined;
runEmbeddedPiAgent.mockImplementation(async (params: { abortSignal?: AbortSignal }) => {
lastAbortSignal = params.abortSignal;
@@ -1073,7 +1073,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? session id cache", messages: [] },
@@ -1107,7 +1107,7 @@ describe("active-memory plugin", () => {
timeoutMs: 250,
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 25));
return {
@@ -1145,7 +1145,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? log sanitization", messages: [] },
@@ -1179,7 +1179,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const hugeSession = `agent:main:${"x".repeat(500)}`;
await hooks.before_prompt_build(
@@ -1423,7 +1423,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "message",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1451,7 +1451,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "full",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1482,7 +1482,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1536,7 +1536,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1578,7 +1578,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1611,7 +1611,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
@@ -1619,7 +1619,8 @@ describe("active-memory plugin", () => {
messages: [
{
role: "user",
content: "Active Memory: I really do want you to remember that I prefer aisle seats.",
content:
"Active Memory: I really do want you to remember that I prefer aisle seats.",
},
{
role: "user",
@@ -1673,7 +1674,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
maxSummaryChars: 40,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
runEmbeddedPiAgent.mockResolvedValueOnce({
payloads: [
{
@@ -1707,7 +1708,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
maxSummaryChars: 90,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{ prompt: "what wings should i order? prompt-count-check", messages: [] },
@@ -1757,7 +1758,7 @@ describe("active-memory plugin", () => {
transcriptDir: "active-memory-subagents",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
const mkdtempSpy = vi.spyOn(fs, "mkdtemp");
const rmSpy = vi.spyOn(fs, "rm").mockResolvedValue(undefined);
@@ -1801,7 +1802,7 @@ describe("active-memory plugin", () => {
transcriptDir: "C:/temp/escape",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
@@ -1838,7 +1839,7 @@ describe("active-memory plugin", () => {
transcriptDir: "active-memory-subagents",
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined);
await hooks.before_prompt_build(
@@ -1905,7 +1906,7 @@ describe("active-memory plugin", () => {
agents: ["main"],
logging: true,
};
plugin.register(api as unknown as OpenClawPluginApi);
await plugin.register(api as unknown as OpenClawPluginApi);
for (let index = 0; index <= 1000; index += 1) {
await hooks.before_prompt_build(

View File

@@ -1527,9 +1527,7 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] {
}
const rawText = extractTextContent(typed.content);
const text =
role === "assistant"
? stripRecalledContextNoise(rawText)
: stripInjectedActiveMemoryPrefixOnly(rawText);
role === "assistant" ? stripRecalledContextNoise(rawText) : stripInjectedActiveMemoryPrefixOnly(rawText);
if (!text) {
continue;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/alibaba-provider",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Alibaba Model Studio video provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/amazon-bedrock-mantle-provider",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
"type": "module",

View File

@@ -1,37 +0,0 @@
import {
isMissingEmbeddingApiKeyError,
type MemoryEmbeddingProviderAdapter,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import {
createBedrockEmbeddingProvider,
DEFAULT_BEDROCK_EMBEDDING_MODEL,
} from "./embedding-provider.js";
export const bedrockMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: "bedrock",
defaultModel: DEFAULT_BEDROCK_EMBEDDING_MODEL,
transport: "remote",
authProviderId: "amazon-bedrock",
autoSelectPriority: 60,
allowExplicitWhenConfiguredAuto: true,
shouldContinueAutoSelection: isMissingEmbeddingApiKeyError,
create: async (options) => {
const { provider, client } = await createBedrockEmbeddingProvider({
...options,
provider: "bedrock",
fallback: "none",
});
return {
provider,
runtime: {
id: "bedrock",
cacheKeyData: {
provider: "bedrock",
region: client.region,
model: client.model,
dimensions: client.dimensions,
},
},
};
},
};

View File

@@ -2,9 +2,6 @@
"id": "amazon-bedrock",
"enabledByDefault": true,
"providers": ["amazon-bedrock"],
"contracts": {
"memoryEmbeddingProviders": ["bedrock"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -1,13 +1,11 @@
{
"name": "@openclaw/amazon-bedrock-provider",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Amazon Bedrock provider plugin",
"type": "module",
"dependencies": {
"@aws-sdk/client-bedrock": "3.1028.0",
"@aws-sdk/client-bedrock-runtime": "3.1028.0",
"@aws-sdk/credential-provider-node": "3.972.30"
"@aws-sdk/client-bedrock": "3.1028.0"
},
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"

View File

@@ -14,7 +14,6 @@ import {
resolveBedrockConfigApiKey,
resolveImplicitBedrockProvider,
} from "./api.js";
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
type GuardrailConfig = {
guardrailIdentifier: string;
@@ -79,8 +78,6 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
const pluginConfig = (api.pluginConfig ?? {}) as AmazonBedrockPluginConfig;
const guardrail = pluginConfig.guardrail;
api.registerMemoryEmbeddingProvider(bedrockMemoryEmbeddingProviderAdapter);
const baseWrapStreamFn = ({ modelId, streamFn }: { modelId: string; streamFn?: StreamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn);

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-vertex-provider",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Anthropic Vertex provider plugin",
"type": "module",

View File

@@ -1,5 +1,4 @@
export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js";
export { buildAnthropicProvider } from "./register.runtime.js";
export {
createAnthropicBetaHeadersWrapper,
createAnthropicFastModeWrapper,

View File

@@ -199,25 +199,7 @@ describe("anthropic provider replay hooks", () => {
provider: "anthropic",
modelId: "claude-opus-4-7",
} as never),
).toBe("off");
expect(
provider.resolveDefaultThinkingLevel?.({
provider: "anthropic",
modelId: "claude-opus-4-6",
} as never),
).toBe("adaptive");
expect(
provider.supportsXHighThinking?.({
provider: "anthropic",
modelId: "claude-opus-4-7",
} as never),
).toBe(true);
expect(
provider.supportsXHighThinking?.({
provider: "anthropic",
modelId: "claude-opus-4-6",
} as never),
).toBe(false);
});
it("resolves claude-cli synthetic oauth auth", async () => {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Anthropic provider plugin",
"type": "module",

View File

@@ -1,59 +0,0 @@
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
const noopAuth = async () => ({ profiles: [] });
export function createAnthropicProvider(): ProviderPlugin {
return {
id: "anthropic",
label: "Anthropic",
docsPath: "/providers/models",
hookAliases: ["claude-cli"],
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
auth: [
{
id: "cli",
kind: "custom",
label: "Claude CLI",
hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*",
run: noopAuth,
wizard: {
choiceId: "anthropic-cli",
choiceLabel: "Anthropic Claude CLI",
choiceHint: "Reuse a local Claude CLI login on this host",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key",
},
},
{
id: "setup-token",
kind: "token",
label: "Anthropic setup-token",
hint: "Manual bearer token path",
run: noopAuth,
wizard: {
choiceId: "setup-token",
choiceLabel: "Anthropic setup-token",
choiceHint: "Manual token path",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key + token",
},
},
{
id: "api-key",
kind: "api_key",
label: "Anthropic API key",
hint: "Direct Anthropic API key",
run: noopAuth,
wizard: {
choiceId: "apiKey",
choiceLabel: "Anthropic API key",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "Claude CLI + API key",
},
},
],
};
}

View File

@@ -18,10 +18,7 @@ import {
upsertAuthProfile,
validateAnthropicSetupToken,
} from "openclaw/plugin-sdk/provider-auth";
import {
cloneFirstTemplateModel,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import * as claudeCliAuth from "./cli-auth-seam.js";
@@ -263,6 +260,8 @@ function resolveAnthropicForwardCompatModel(
function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
return (
lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_SONNET_46_MODEL_ID) ||
@@ -270,14 +269,6 @@ function shouldUseAnthropicAdaptiveThinkingDefault(modelId: string): boolean {
);
}
function isAnthropicOpus47Model(modelId: string): boolean {
const lowerModelId = normalizeLowercaseStringOrEmpty(modelId);
return (
lowerModelId.startsWith(ANTHROPIC_OPUS_47_MODEL_ID) ||
lowerModelId.startsWith(ANTHROPIC_OPUS_47_DOT_MODEL_ID)
);
}
function matchesAnthropicModernModel(modelId: string): boolean {
const lower = normalizeLowercaseStringOrEmpty(modelId);
return ANTHROPIC_MODERN_MODEL_PREFIXES.some((prefix) => lower.startsWith(prefix));
@@ -398,10 +389,11 @@ async function runAnthropicCliMigrationNonInteractive(ctx: {
};
}
export function buildAnthropicProvider(): ProviderPlugin {
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
const providerId = "anthropic";
const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL;
return {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider({
id: providerId,
label: "Anthropic",
docsPath: "/providers/models",
@@ -489,14 +481,11 @@ export function buildAnthropicProvider(): ProviderPlugin {
buildReplayPolicy: buildAnthropicReplayPolicy,
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
resolveReasoningOutputMode: () => "native",
supportsXHighThinking: ({ modelId }) => isAnthropicOpus47Model(modelId),
wrapStreamFn: wrapAnthropicProviderStream,
resolveDefaultThinkingLevel: ({ modelId }) =>
isAnthropicOpus47Model(modelId)
? "off"
: matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
? "adaptive"
: undefined,
matchesAnthropicModernModel(modelId) && shouldUseAnthropicAdaptiveThinkingDefault(modelId)
? "adaptive"
: undefined,
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
fetchUsageSnapshot: async (ctx) =>
await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
@@ -507,11 +496,6 @@ export function buildAnthropicProvider(): ProviderPlugin {
store: ctx.store,
profileId: ctx.profileId,
}),
};
}
export function registerAnthropicPlugin(api: OpenClawPluginApi): void {
api.registerCliBackend(buildAnthropicCliBackend());
api.registerProvider(buildAnthropicProvider());
});
api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/arcee-provider",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Arcee provider plugin",
"type": "module",

View File

@@ -2,7 +2,3 @@ export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export {
__testing as blueBubblesConversationBindingTesting,
createBlueBubblesConversationBindingManager,
} from "./src/conversation-bindings.js";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {
@@ -8,7 +8,7 @@
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.16"
"openclaw": ">=2026.4.15-beta.1"
},
"peerDependenciesMeta": {
"openclaw": {
@@ -43,10 +43,10 @@
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.16"
"pluginApi": ">=2026.4.15-beta.1"
},
"build": {
"openclawVersion": "2026.4.16"
"openclawVersion": "2026.4.15-beta.1"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,10 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import {
downloadBlueBubblesAttachment,
fetchBlueBubblesMessageAttachments,
sendBlueBubblesAttachment,
} from "./attachments.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import { fetchBlueBubblesServerInfo, getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import type { PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
@@ -773,86 +769,3 @@ describe("sendBlueBubblesAttachment", () => {
).rejects.toThrow("chatGuid not found");
});
});
describe("fetchBlueBubblesMessageAttachments", () => {
beforeEach(() => {
mockFetch.mockReset();
});
it("returns attachments from the BB API response", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
attachments: [
{
guid: "att-1",
mimeType: "image/jpeg",
transferName: "photo.jpg",
totalBytes: 1024,
},
{
guid: "att-2",
mime_type: "image/png",
transfer_name: "screenshot.png",
total_bytes: 2048,
},
],
},
}),
});
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
baseUrl: "http://localhost:1234",
password: "test",
});
expect(result).toHaveLength(2);
expect(result[0].guid).toBe("att-1");
expect(result[0].mimeType).toBe("image/jpeg");
expect(result[1].guid).toBe("att-2");
expect(result[1].mimeType).toBe("image/png");
});
it("returns empty array on non-ok HTTP response", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
});
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
baseUrl: "http://localhost:1234",
password: "test",
});
expect(result).toEqual([]);
});
it("returns empty array when data has no attachments", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: {} }),
});
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
baseUrl: "http://localhost:1234",
password: "test",
});
expect(result).toEqual([]);
});
it("includes entries without a guid (downstream download handles filtering)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: {
attachments: [{ mimeType: "image/jpeg" }, { guid: "att-valid", mimeType: "image/png" }],
},
}),
});
const result = await fetchBlueBubblesMessageAttachments("msg-guid", {
baseUrl: "http://localhost:1234",
password: "test",
});
expect(result).toHaveLength(2);
expect(result[0].guid).toBeUndefined();
expect(result[1].guid).toBe("att-valid");
});
});

View File

@@ -8,7 +8,6 @@ import {
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
import { extractAttachments } from "./monitor-normalize.js";
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
import {
fetchBlueBubblesServerInfo,
@@ -27,12 +26,8 @@ import {
type SsrFPolicy,
} from "./types.js";
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy | undefined {
// Pass `undefined` (not `{}`) for the non-private case so the non-SSRF fallback path
// is used. An empty `{}` policy routes through the SSRF guard, which blocks the
// localhost BB deployments that are the most common self-hosted setup. The opt-in
// private-network branch keeps the explicit policy. (#64105, #67510)
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined): SsrFPolicy {
return allowPrivateNetwork ? { allowPrivateNetwork: true } : {};
}
export type BlueBubblesAttachmentOpts = {
@@ -100,51 +95,6 @@ function readMediaFetchErrorCode(error: unknown): MediaFetchErrorCode | undefine
: undefined;
}
/**
* Fetch attachment metadata for a message from the BlueBubbles API.
*
* BlueBubbles sometimes fires the `new-message` webhook before attachment
* indexing is complete, so `attachments` arrives as `[]`. This function
* GETs the message by GUID and returns whatever attachments the server
* has indexed by now. (#65430, #67437)
*/
export async function fetchBlueBubblesMessageAttachments(
messageGuid: string,
opts: {
baseUrl: string;
password: string;
timeoutMs?: number;
allowPrivateNetwork?: boolean;
},
): Promise<BlueBubblesAttachment[]> {
const url = buildBlueBubblesApiUrl({
baseUrl: opts.baseUrl,
path: `/api/v1/message/${encodeURIComponent(messageGuid)}`,
password: opts.password,
});
// Pass undefined (not {}) when private network is not opted-in so the
// non-SSRF fallback path is used — an empty {} triggers the SSRF-guarded
// path which blocks localhost BB servers by default. (#64105)
const policy: SsrFPolicy | undefined = opts.allowPrivateNetwork
? { allowPrivateNetwork: true }
: undefined;
const response = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
opts.timeoutMs,
policy,
);
if (!response.ok) {
return [];
}
const json = (await response.json()) as Record<string, unknown>;
const data = json.data as Record<string, unknown> | undefined;
if (!data) {
return [];
}
return extractAttachments(data);
}
export async function downloadBlueBubblesAttachment(
attachment: BlueBubblesAttachment,
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},

View File

@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it } from "vitest";
import {
_resetBlueBubblesInboundDedupForTest,
claimBlueBubblesInboundMessage,
resolveBlueBubblesInboundDedupeKey,
} from "./inbound-dedupe.js";
async function claimAndFinalize(guid: string | undefined, accountId: string): Promise<string> {
@@ -57,38 +56,3 @@ describe("claimBlueBubblesInboundMessage", () => {
expect(await claimAndFinalize("g1", "acc")).toBe("claimed");
});
});
describe("resolveBlueBubblesInboundDedupeKey", () => {
it("returns messageId for new-message events", () => {
expect(resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" })).toBe("msg-1");
});
it("returns associatedMessageGuid for balloon events", () => {
expect(
resolveBlueBubblesInboundDedupeKey({
messageId: "balloon-1",
balloonBundleId: "com.apple.messages.URLBalloonProvider",
associatedMessageGuid: "msg-1",
}),
).toBe("msg-1");
});
it("suffixes key with :updated for updated-message events", () => {
expect(
resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1", eventType: "updated-message" }),
).toBe("msg-1:updated");
});
it("updated-message and new-message for same GUID produce distinct keys", () => {
const newKey = resolveBlueBubblesInboundDedupeKey({ messageId: "msg-1" });
const updatedKey = resolveBlueBubblesInboundDedupeKey({
messageId: "msg-1",
eventType: "updated-message",
});
expect(newKey).not.toBe(updatedKey);
});
it("returns undefined when messageId is missing", () => {
expect(resolveBlueBubblesInboundDedupeKey({})).toBeUndefined();
});
});

View File

@@ -136,27 +136,15 @@ function sanitizeGuid(guid: string | undefined | null): string | null {
export function resolveBlueBubblesInboundDedupeKey(
message: Pick<
NormalizedWebhookMessage,
"messageId" | "balloonBundleId" | "associatedMessageGuid" | "eventType"
"messageId" | "balloonBundleId" | "associatedMessageGuid"
>,
): string | undefined {
const balloonBundleId = message.balloonBundleId?.trim();
const associatedMessageGuid = message.associatedMessageGuid?.trim();
let base: string | undefined;
if (balloonBundleId && associatedMessageGuid) {
base = associatedMessageGuid;
} else {
base = message.messageId?.trim() || undefined;
return associatedMessageGuid;
}
if (!base) {
return undefined;
}
// `updated-message` events get a distinct key so they are not rejected as
// duplicates of the already-committed `new-message` for the same GUID.
// This lets attachment-carrying follow-up webhooks through. (#65430, #52277)
if (message.eventType === "updated-message") {
return `${base}:updated`;
}
return base;
return message.messageId?.trim() || undefined;
}
export type InboundDedupeClaim =

View File

@@ -34,7 +34,7 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
return parseFiniteNumber(record[key]);
}
export function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
const raw = message["attachments"];
if (!Array.isArray(raw)) {
return [];
@@ -477,8 +477,6 @@ export type NormalizedWebhookMessage = {
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
/** Webhook event type preserved for dedup key differentiation. */
eventType?: string;
};
export type NormalizedWebhookReaction = {
@@ -689,7 +687,6 @@ function extractMessagePayload(payload: Record<string, unknown>): Record<string,
export function normalizeWebhookMessage(
payload: Record<string, unknown>,
options?: { eventType?: string },
): NormalizedWebhookMessage | null {
const message = extractMessagePayload(payload);
if (!message) {
@@ -777,7 +774,6 @@ export function normalizeWebhookMessage(
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
replyToSender: replyMetadata.replyToSender,
eventType: options?.eventType,
};
}

View File

@@ -9,10 +9,7 @@ import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/text-runtime";
import {
downloadBlueBubblesAttachment,
fetchBlueBubblesMessageAttachments,
} from "./attachments.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveBlueBubblesConversationRoute } from "./conversation-route.js";
import { fetchBlueBubblesHistory } from "./history.js";
@@ -695,52 +692,8 @@ async function processMessageAfterDedupe(
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
const text = message.text.trim();
let attachments = message.attachments ?? [];
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
const password = normalizeSecretInputString(account.config.password);
// BlueBubbles may fire the webhook before attachment indexing is complete,
// so the initial `attachments` array can be empty for messages that actually
// have media. When the message text is empty (image-only) or this is an
// `updated-message` event, wait briefly and re-fetch from the BB API as a
// fallback for cases where BB doesn't send a follow-up webhook. (#65430, #67437)
// This must run before the !rawBody guard below, otherwise image-only messages
// with empty attachments are dropped before the retry can fire.
const retryMessageId = message.messageId?.trim();
const shouldRetryAttachments =
attachments.length === 0 &&
retryMessageId &&
baseUrl &&
password &&
(text.length === 0 || message.eventType === "updated-message");
if (shouldRetryAttachments) {
try {
await new Promise<void>((resolve) => setTimeout(resolve, 2_000));
const fetched = await fetchBlueBubblesMessageAttachments(retryMessageId, {
baseUrl,
password,
timeoutMs: 10_000,
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
});
if (fetched.length > 0) {
logVerbose(
core,
runtime,
`attachment retry found ${fetched.length} attachment(s) for msgId=${message.messageId}`,
);
attachments = fetched;
}
} catch (err) {
logVerbose(
core,
runtime,
`attachment retry failed for msgId=${message.messageId}: ${String(err)}`,
);
}
}
// Recompute placeholder from resolved attachments (may have been updated by retry).
const placeholder = buildMessagePlaceholder({ ...message, attachments });
const attachments = message.attachments ?? [];
const placeholder = buildMessagePlaceholder(message);
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
const tapbackContext = resolveTapbackContext(message);
@@ -1066,6 +1019,9 @@ async function processMessageAfterDedupe(
return;
}
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
const password = normalizeSecretInputString(account.config.password);
if (isGroup && !message.participants?.length && baseUrl && password) {
try {
const fetchedParticipants = await fetchBlueBubblesParticipantsForInboundMessage({

View File

@@ -249,22 +249,11 @@ export async function handleBlueBubblesWebhookRequest(
return true;
}
const reaction = normalizeWebhookReaction(payload);
// Normalize the webhook message early so the attachment-update detection
// below sees attachments under any supported wrapper format (`payload.data`,
// `payload.message`, `payload.data.message`, JSON-string payloads), not just
// raw `payload.data.attachments`. (#65430, #67510)
const message = reaction ? null : normalizeWebhookMessage(payload, { eventType });
// BlueBubbles fires `updated-message` when attachments are indexed after the
// initial `new-message` (which may arrive with attachments: []). Let those
// through so the agent can ingest the image. (#65430)
const isAttachmentUpdate =
eventType === "updated-message" && (message?.attachments?.length ?? 0) > 0;
if (
(eventType === "updated-message" ||
eventType === "message-reaction" ||
eventType === "reaction") &&
!reaction &&
!isAttachmentUpdate
!reaction
) {
res.statusCode = 200;
res.end("ok");
@@ -272,11 +261,12 @@ export async function handleBlueBubblesWebhookRequest(
logVerbose(
firstTarget.core,
firstTarget.runtime,
`webhook ignored ${eventType || "event"} (no reaction or attachment update)`,
`webhook ignored ${eventType || "event"} without reaction`,
);
}
return true;
}
const message = reaction ? null : normalizeWebhookMessage(payload);
if (!message && !reaction) {
res.statusCode = 400;
res.end("invalid payload");

View File

@@ -175,18 +175,10 @@ export async function blueBubblesFetchWithTimeout(
await release();
}
}
// Strip `dispatcher` from init — the SSRF guard may have attached a bundled-undici
// dispatcher that is incompatible with Node 22+'s built-in undici backing globalThis.fetch().
// Passing it through causes a silent TypeError (invalid onRequestStart method).
// The SSRF validation already completed upstream in fetchWithSsrFGuard before calling
// this function as fetchImpl, so stripping the dispatcher does not weaken security. (#64105)
const { dispatcher: _dispatcher, ...safeInit } = (init ?? {}) as RequestInit & {
dispatcher?: unknown;
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...safeInit, signal: controller.signal });
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw Brave plugin",
"type": "module",

View File

@@ -1,8 +1,7 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js";
import { __testing } from "../test-api.js";
import { createBraveWebSearchProvider } from "./brave-web-search-provider.js";
import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js";
const braveManifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"),

View File

@@ -3,59 +3,25 @@ import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
} from "openclaw/plugin-sdk/provider-web-search";
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract";
import { isRecord } from "openclaw/plugin-sdk/text-runtime";
import {
createBraveSchema,
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveConfig,
resolveBraveMode,
} from "./brave-web-search-provider.shared.js";
const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey";
const BraveSearchSchema = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "number",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
country: {
type: "string",
description:
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
},
language: {
type: "string",
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
},
freshness: {
type: "string",
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
},
date_after: {
type: "string",
description: "Only results published after this date (YYYY-MM-DD).",
},
date_before: {
type: "string",
description: "Only results published before this date (YYYY-MM-DD).",
},
search_lang: {
type: "string",
description:
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
},
ui_lang: {
type: "string",
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
},
},
} satisfies Record<string, unknown>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
type ConfigInput = Parameters<
NonNullable<WebSearchProviderPlugin["getConfiguredCredentialValue"]>
>[0];
type ConfigTarget = Parameters<
NonNullable<WebSearchProviderPlugin["setConfiguredCredentialValue"]>
>[0];
function resolveProviderWebSearchPluginConfig(
config: unknown,
config: ConfigInput,
pluginId: string,
): Record<string, unknown> | undefined {
if (!isRecord(config)) {
@@ -68,6 +34,40 @@ function resolveProviderWebSearchPluginConfig(
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
}
function ensureObject(target: Record<string, unknown>, key: string): Record<string, unknown> {
const current = target[key];
if (isRecord(current)) {
return current;
}
const next: Record<string, unknown> = {};
target[key] = next;
return next;
}
function setProviderWebSearchPluginConfigValue(
configTarget: ConfigTarget,
pluginId: string,
key: string,
value: unknown,
): void {
const plugins = ensureObject(configTarget as Record<string, unknown>, "plugins");
const entries = ensureObject(plugins, "entries");
const entry = ensureObject(entries, pluginId);
if (entry.enabled === undefined) {
entry.enabled = true;
}
const config = ensureObject(entry, "config");
const webSearch = ensureObject(config, "webSearch");
webSearch[key] = value;
}
function setTopLevelCredentialValue(
searchConfigTarget: Record<string, unknown>,
value: unknown,
): void {
searchConfigTarget.apiKey = value;
}
function mergeScopedSearchConfig(
searchConfig: Record<string, unknown> | undefined,
key: string,
@@ -94,22 +94,17 @@ function mergeScopedSearchConfig(
return next;
}
function resolveBraveMode(searchConfig?: Record<string, unknown>): "web" | "llm-context" {
const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined;
return brave?.mode === "llm-context" ? "llm-context" : "web";
}
function createBraveToolDefinition(
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveMode = resolveBraveMode(searchConfig);
const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig));
return {
description:
braveMode === "llm-context"
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: BraveSearchSchema,
parameters: createBraveSchema(),
execute: async (args) => {
const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js");
return await executeBraveSearch(args, searchConfig);
@@ -129,12 +124,15 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
credentialPath: BRAVE_CREDENTIAL_PATH,
...createWebSearchProviderContractFields({
credentialPath: BRAVE_CREDENTIAL_PATH,
searchCredential: { type: "top-level" },
configuredCredential: { pluginId: "brave" },
}),
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: setTopLevelCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: (ctx) =>
createBraveToolDefinition(
mergeScopedSearchConfig(
@@ -146,3 +144,10 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
),
};
}
export const __testing = {
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;

View File

@@ -1,13 +1 @@
import {
mapBraveLlmContextResults,
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
} from "./src/brave-web-search-provider.shared.js";
export const __testing = {
normalizeBraveCountry,
normalizeBraveLanguageParams,
resolveBraveMode,
mapBraveLlmContextResults,
} as const;
export { __testing } from "./src/brave-web-search-provider.js";

View File

@@ -1 +1 @@
export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.4.16",
"version": "2026.4.15-beta.1",
"private": true,
"description": "OpenClaw browser tool plugin",
"type": "module",

View File

@@ -1,46 +0,0 @@
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
retryDelayMs?: number;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
): { targetId: string; shouldRetry: boolean } => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return { targetId: opts.oldTargetId, shouldRetry: false };
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return { targetId: byUrl[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return {
targetId: uniqueReplacement[0]?.targetId ?? opts.oldTargetId,
shouldRetry: false,
};
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return { targetId: tabs[0]?.targetId ?? opts.oldTargetId, shouldRetry: false };
}
return { targetId: opts.oldTargetId, shouldRetry: true };
};
const first = pickReplacement(await opts.listTabs());
currentTargetId = first.targetId;
if (first.shouldRetry) {
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
}).targetId;
}
} catch {
// Best-effort: fall back to pre-navigation targetId.
}
return currentTargetId;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot.js";
type Tab = { targetId: string; url: string };
@@ -8,6 +8,10 @@ function staticListTabs(tabs: Tab[]): () => Promise<Tab[]> {
}
describe("resolveTargetIdAfterNavigate", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("returns original targetId when old target still exists (no swap)", async () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
@@ -33,7 +37,6 @@ describe("resolveTargetIdAfterNavigate", () => {
const result = await resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
@@ -44,12 +47,12 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("retries and resolves targetId when first listTabs has no URL match", async () => {
vi.useFakeTimers();
let calls = 0;
const result = await resolveTargetIdAfterNavigate({
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://delayed.com",
retryDelayMs: 0,
listTabs: async () => {
calls++;
if (calls === 1) {
@@ -59,33 +62,50 @@ describe("resolveTargetIdAfterNavigate", () => {
},
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("delayed-999");
expect(calls).toBe(2);
vi.useRealTimers();
});
it("falls back to original targetId when no match found after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://no-match.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "unrelated-1", url: "https://unrelated.com" },
{ targetId: "unrelated-2", url: "https://unrelated2.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
it("falls back to single remaining tab when no URL match after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://single-tab.com",
retryDelayMs: 0,
listTabs: staticListTabs([{ targetId: "only-tab", url: "https://some-other.com" }]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("only-tab");
vi.useRealTimers();
});
it("falls back to original targetId when listTabs throws", async () => {
@@ -100,16 +120,22 @@ describe("resolveTargetIdAfterNavigate", () => {
});
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
const result = await resolveTargetIdAfterNavigate({
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
retryDelayMs: 0,
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
});

View File

@@ -32,7 +32,6 @@ import {
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import { resolveTargetIdAfterNavigate } from "./agent.snapshot-target.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
@@ -173,6 +172,48 @@ async function saveBrowserMediaResponse(params: {
});
}
/** Resolve the correct targetId after a navigation that may trigger a renderer swap. */
export async function resolveTargetIdAfterNavigate(opts: {
oldTargetId: string;
navigatedUrl: string;
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const pickReplacement = (
tabs: Array<{ targetId: string; url: string }>,
options?: { allowSingleTabFallback?: boolean },
) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (options?.allowSingleTabFallback && tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs(), {
allowSingleTabFallback: true,
});
}
} catch {
// Best-effort: fall back to pre-navigation targetId
}
return currentTargetId;
}
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,

View File

@@ -7,7 +7,6 @@ import {
PROFILE_POST_RESTART_WS_TIMEOUT_MS,
resolveCdpReachabilityTimeouts,
} from "./cdp-timeouts.js";
import { redactCdpUrl } from "./cdp.helpers.js";
import {
closeChromeMcpSession,
ensureChromeMcpAvailable,
@@ -60,7 +59,6 @@ export function createProfileAvailability({
getProfileState,
setProfileRunning,
}: AvailabilityDeps): AvailabilityOps {
const redactedProfileCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl;
const capabilities = getBrowserProfileCapabilities(profile);
const resolveTimeouts = (timeoutMs: number | undefined) =>
resolveCdpReachabilityTimeouts({
@@ -212,7 +210,7 @@ export function createProfileAvailability({
if (attachOnly || remoteCdp) {
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP for profile "${profile.name}" is not reachable at ${redactedProfileCdpUrl}.`
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
);
}

View File

@@ -5,7 +5,6 @@ import {
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
} from "./cdp-timeouts.js";
import * as chromeModule from "./chrome.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { createBrowserRouteContext } from "./server-context.js";
import { makeBrowserServerState, mockLaunchedChrome } from "./server-context.test-harness.js";
@@ -176,39 +175,4 @@ describe("browser server-context ensureBrowserAvailable", () => {
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
it("redacts credentials in remote CDP availability errors", async () => {
const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness();
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
const state = makeBrowserServerState({
profile: {
name: "remote",
cdpUrl: "https://user:pass@browserless.example.com?token=supersecret123",
cdpHost: "browserless.example.com",
cdpIsLoopback: false,
cdpPort: 443,
color: "#00AA00",
driver: "openclaw",
attachOnly: false,
},
resolvedOverrides: {
defaultProfile: "remote",
ssrfPolicy: {},
},
});
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("remote");
isChromeReachable.mockResolvedValue(false);
const promise = profile.ensureBrowserAvailable();
await expect(promise).rejects.toThrow(BrowserProfileUnavailableError);
await expect(promise).rejects.toThrow(
'Remote CDP for profile "remote" is not reachable at https://browserless.example.com/?token=***.',
);
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import {
installAgentContractHooks,
startServerAndBase,
} from "./server.agent-contract.test-harness.js";
import {
setBrowserControlServerEvaluateEnabled,
setBrowserControlServerProfiles,
} from "./server.control-server.test-harness.js";
import { getBrowserTestFetch } from "./test-fetch.js";
type ActErrorResponse = {
error?: string;
code?: string;
};
type ActErrorHttpResponse = {
status: number;
body: ActErrorResponse;
};
async function postActAndReadError(base: string, body?: unknown): Promise<ActErrorHttpResponse> {
const realFetch = getBrowserTestFetch();
const response = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
return {
status: response.status,
body: (await response.json()) as ActErrorResponse,
};
}
describe("browser control server", () => {
installAgentContractHooks();
const slowTimeoutMs = process.platform === "win32" ? 40_000 : 20_000;
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed action payloads",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: {},
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("click requires ref or selector");
},
slowTimeoutMs,
);
it(
"returns ACT_EXISTING_SESSION_UNSUPPORTED for unsupported existing-session actions",
async () => {
setBrowserControlServerProfiles({
openclaw: {
color: "#FF4500",
driver: "existing-session",
},
});
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "press", key: "Enter" }],
});
expect(response.status).toBe(501);
expect(response.body.code).toBe("ACT_EXISTING_SESSION_UNSUPPORTED");
expect(response.body.error).toContain("batch");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for batched action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "batch",
actions: [{ kind: "click", ref: "5", targetId: "other-tab" }],
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("batched action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_TARGET_ID_MISMATCH for top-level action targetId overrides",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "click",
ref: "5",
// Intentionally non-string: route-level target selection ignores this,
// while action normalization stringifies it.
targetId: 12345,
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_TARGET_ID_MISMATCH");
expect(response.body.error).toContain("action targetId must match request targetId");
},
slowTimeoutMs,
);
it(
"returns ACT_SELECTOR_UNSUPPORTED for selector on unsupported action kinds",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_SELECTOR_UNSUPPORTED");
expect(response.body.error).toContain("'selector' is not supported");
},
slowTimeoutMs,
);
it(
"returns ACT_INVALID_REQUEST for malformed unsupported selector actions before selector gating",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "press",
selector: "#submit",
});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_INVALID_REQUEST");
expect(response.body.error).toContain("press requires key");
},
slowTimeoutMs,
);
it(
"returns ACT_EVALUATE_DISABLED when evaluate is blocked by config",
async () => {
setBrowserControlServerEvaluateEnabled(false);
const base = await startServerAndBase();
const response = await postActAndReadError(base, {
kind: "evaluate",
fn: "() => 1",
});
expect(response.status).toBe(403);
expect(response.body.code).toBe("ACT_EVALUATE_DISABLED");
expect(response.body.error).toContain("browser.evaluateEnabled=false");
},
slowTimeoutMs,
);
});

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